fix: catch formatUsdcMicro throw in ActivityTicker for malformed amounts#13
fix: catch formatUsdcMicro throw in ActivityTicker for malformed amounts#13toon-backlog-bot[bot] wants to merge 3 commits into
Conversation
formatUsdcMicro throws in NODE_ENV=test/development for invalid input but returns '$?.????' only in production. ActivityTicker had no error handling, so Ink's error boundary swallowed the throw and rendered an empty frame. Wrapping the call in try/catch makes the component degrade gracefully in all environments, matching the production contract the test asserts. Closes #12 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ALLiDoizCode
left a comment
There was a problem hiding this comment.
Adversarial Review — Request Changes
Blocking: ActivityOverlay has the identical uncaught call site — fix is incomplete
The PR fixes ActivityTicker.tsx:34 but leaves ActivityOverlay.tsx:41 in the same broken state:
// ActivityOverlay.tsx — formatRow() — NO try/catch
const amount = formatUsdcMicro(claim.amount, claim.assetScale);In test / development, formatUsdcMicro throws for malformed amounts (by design). ActivityOverlay calls it bare inside formatRow, which is called for every visible row. Any malformed claim amount in the overlay still crashes the component (Ink error-boundary swallows it to an empty frame, exactly the symptom described in the PR). There is no test in ActivityOverlay.test.tsx covering malformed amounts, so it will silently regress again.
Required fix: apply the same try/catch pattern (or export the fallback constant and guard the call) inside formatRow in ActivityOverlay.tsx.
Non-blocking: Duplicated fallback string '$?.????'
format.ts:31 returns '$?.????' on the production path; ActivityTicker.tsx:36 now also hardcodes '$?.????' in the catch block. Two copies of a magic string in two files — if the fallback string ever changes in format.ts, ActivityTicker silently drifts. Export a named constant (USDC_MICRO_FALLBACK or similar) from format.ts and reference it in both the function body and the catch block. Same fix should apply to the formatUsdc / '$?.??' fallback when its call sites get the same treatment.
What was checked
- Full diff of
ActivityTicker.tsx format.ts—formatUsdcMicrofail-fast logic (throws in test/dev, returns fallback in prod)format.test.ts:87-90— correctly overridesNODE_ENV='production'to test the return path; test suite is self-consistentActivityOverlay.tsx:41— identical unguarded call, confirmed no malformed-amount test inActivityOverlay.test.tsxActivityTicker.test.tsx:107-115— the targeted test, now reachable and correct after the fix- All 9 assertions in
ActivityTicker.test.tsxverified as logically sound
The ActivityTicker change itself is correct; the problem is incompleteness.
…constant - Add try/catch in ActivityOverlay.formatRow() around formatUsdcMicro — same fix as ActivityTicker; malformed amounts no longer crash the overlay. - Export USDC_MICRO_FALLBACK from format.ts; use it in the function body and in both component catch blocks so the fallback string has one source of truth. - Add malformed-amount test to ActivityOverlay.test.tsx (regression coverage). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix-loop response (review-round:1)Addressed both review points in commit 728d7c4. Blocking — ActivityOverlay unguarded call siteApplied the same try/catch pattern to let amount: string;
try {
amount = formatUsdcMicro(claim.amount, claim.assetScale);
} catch {
amount = USDC_MICRO_FALLBACK;
}Also added a malformed-amount test to it('malformed amount renders USDC_MICRO_FALLBACK (no crash)', () => {
const claim = makeClaim({ amount: 'bad' });
// ... expects '$?.????'
});Non-blocking — duplicated fallback stringExported Verification |
ALLiDoizCode
left a comment
There was a problem hiding this comment.
Review findings
The fix for ActivityTicker and ActivityOverlay is correct and the constant extraction is a good refactor. However, the same vulnerability exists in at least two other components that this PR leaves unaddressed — and one of them has an actively fragile test workaround that makes the gap visible.
Blocking: incomplete fix — ApexStrip and PeerTable have the same vulnerability
ApexStrip.tsx:37 calls formatUsdc(apexMonth, USDC_SCALE) where apexMonth can be a non-decimal string from wire data (apex.routingFees[ASSET]?.month ?? '0'). formatUsdc has exactly the same fail-fast behaviour as formatUsdcMicro: throws in NODE_ENV=test|development, returns '$?.??' in production.
The evidence that this is the same bug: ApexStrip.test.tsx:86–99 works around it by temporarily mutating process.env['NODE_ENV'] to 'production' before rendering with malformed input, then restoring it in finally. That workaround exists for the same reason this PR was opened — the component is called without try/catch and the formatter throws in the test environment.
// ApexStrip.test.tsx:83–100
it('renders no upsell and no percentage when apex.month is malformed (production fallback)', () => {
const origEnv = process.env['NODE_ENV'];
process.env['NODE_ENV'] = 'production'; // ← same workaround as was needed for ActivityTicker
try { ... } finally { process.env['NODE_ENV'] = origEnv; }
});
PeerTable.tsx:86 similarly calls formatUsdc(row.perAsset.month, USDC_SCALE) inside a .map() with no try/catch, where row.perAsset.month comes from wire data.
HeroBand.tsx:87–90 calls formatUsdc four times. Here the values come from computeScalars (BigInt arithmetic), so the inputs are likely always valid — but this should be confirmed rather than assumed.
Minor (non-blocking, should be addressed in the same pass)
The new test in ActivityOverlay.test.tsx:203 uses the hardcoded string '$?.????' instead of the exported USDC_MICRO_FALLBACK constant. The whole point of extracting and exporting that constant was so call sites (including tests) don't silently diverge if the fallback string ever changes. The existing ActivityTicker.test.tsx:114 has the same issue — worth fixing both while touching these files.
What's correct
- The
ActivityTickertry/catch is the right fix, correctly applied. - The
ActivityOverlayextension (not in the original issue) was a good catch. USDC_MICRO_FALLBACKas a named export is the right refactor.- The new overlay test correctly verifies the no-crash contract.
Please apply the same try/catch pattern to ApexStrip and PeerTable, delete the NODE_ENV=production workaround from ApexStrip.test.tsx (replacing it with the same malformed-amount approach used in the new overlay test), and import USDC_MICRO_FALLBACK in the tests that assert on it.
…t USDC_FALLBACK - Export USDC_FALLBACK constant from format.ts (mirrors USDC_MICRO_FALLBACK pattern) - Wrap formatUsdc calls in ApexStrip, PeerTable, and HeroBand with try/catch - Remove NODE_ENV=production workaround from ApexStrip.test.tsx - Add malformed-amount test to PeerTable.test.tsx - Reference USDC_MICRO_FALLBACK/USDC_FALLBACK constants in tests instead of hardcoded strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ALLiDoizCode
left a comment
There was a problem hiding this comment.
Review findings — requesting changes
Blocking: pnpm-lock.yaml contains unannounced, unrelated dependency upgrades
The PR description covers only the TUI try/catch fix, but the lock file includes a batch of dependency changes with no mention in the summary or context:
viem2.47.2 → 2.52.2 — five minor versions forward on a core EVM/wallet library used by hub-web. Not mentioned anywhere.readable-stream3.6.2 → 4.7.0 — a major version bump on a Node.js I/O primitive. Major bumps can have breaking changes (stream API surface changed significantly between v3 and v4).wsreshuffled between 8.20.0 and 8.21.0 with optional-peer resolution changes.bn.js5.2.2 → 5.2.3,debug4.3.4 → 4.4.3,@babel/runtime7.28.6 → 7.29.7,nostr-tools2.23.1 → 2.23.5,semver7.7.4 → 7.8.4,ox0.14.0 → 0.14.29.
These changes also re-sync the specifier fields for @toon-protocol/{client,core,relay,sdk} from ^0.9.2/^1.4.2/^1.3.2/^0.5.1 down to ^0.9.1/^1.4.1/^1.3.1/^0.5.0 (matching the current package.json), suggesting the lock file was regenerated from scratch rather than updated minimally.
Each dependency change needs its own CI validation and rollback surface. Mixing them into a TUI formatting fix makes the diff unauditable and the rollback unit too coarse. Please split into a separate "chore: update dependencies" PR, or at minimum add them to the PR description with rationale and confirm CI passes on hub-web/wallet flows that depend on viem.
Blocking: HeroBand.tsx — single try/catch silently corrupts valid sibling values
// packages/townhouse/src/tui/components/HeroBand.tsx lines 93-103
try {
todayFmt = formatUsdc(scalars.today, USDC_SCALE);
monthFmt = formatUsdc(scalars.month, USDC_SCALE);
yearFmt = formatUsdc(scalars.year, USDC_SCALE);
lifetimeFmt = formatUsdc(scalars.lifetime, USDC_SCALE);
} catch {
todayFmt = USDC_FALLBACK;
monthFmt = USDC_FALLBACK;
yearFmt = USDC_FALLBACK;
lifetimeFmt = USDC_FALLBACK;
}If formatUsdc(scalars.today, ...) throws (in dev/test environments formatUsdc throws for malformed input per format.ts:59-62), the remaining three calls never execute and month, year, lifetime are silently corrupted to USDC_FALLBACK even though their values may be perfectly valid.
The other four components affected by this PR (ActivityTicker, ActivityOverlay, ApexStrip, PeerTable) each wrap a single format call, so they're correctly scoped. Only HeroBand has this multi-value granularity problem.
The comment acknowledges computeScalars always yields valid decimal strings via BigInt arithmetic, but the stated purpose of the guard is "forward-compatibility" — so the guard should correctly scope to individual values. Each format call needs its own try/catch:
const safeFmt = (s: string) => { try { return formatUsdc(s, USDC_SCALE); } catch { return USDC_FALLBACK; } };
const todayFmt = safeFmt(scalars.today);
const monthFmt = safeFmt(scalars.month);
const yearFmt = safeFmt(scalars.year);
const lifetimeFmt = safeFmt(scalars.lifetime);There is no test covering the case where exactly one of the four scalars is malformed, so this bug is currently invisible.
Non-blocking observations (no changes required)
- The constant extraction (
USDC_MICRO_FALLBACK,USDC_FALLBACK) is clean and the test updates to reference these constants instead of inline literals are correct. ActivityTicker,ActivityOverlay,ApexStrip, andPeerTabletry/catch blocks are correctly scoped to single format calls.- The
ApexStriptest rewrite (removing theNODE_ENV=productionenv manipulation) is a genuine improvement — the component-level catch makes the env toggle unnecessary. HeroBandis missing a test covering the malformed-scalar fallback path; the fix for the granularity issue above should be paired with one.
Summary
formatUsdcMicro(claim.amount, claim.assetScale)call inActivityTickerwith a try/catch that renders'$?.????'on error.Root cause
formatUsdcMicrothrows inNODE_ENV=test(anddevelopment) when given a malformed amount string — this is an intentional fail-fast behavior.ActivityTickercalled it without any error handling, so Ink's error boundary caught the throw and rendered an empty frame ('\n'). The testmalformed amount renders formatUsdcMicro fallbackcorrectly asserts the production fallback string$?.????appears, but it was unreachable in the test environment.Fix
Added try/catch in
ActivityTickerso it degrades gracefully to$?.????in all environments, matching the production contract the test asserts. The fail-fast behavior offormatUsdcMicroitself is preserved for direct callers.Verification
Plan deviation
None — the Agent Assessment directed us to find and fix the regression; the root cause analysis in the issue comments identified the contract conflict and this is the component-side fix (option 2 from the analysis).
Closes #12