From 8ea537525a2a0bf57407336ba6b14eaada995109 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 17:36:10 -0700 Subject: [PATCH] fix(rwallet): align timeline inflows/outflows with balance changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use curveStepAfter for the balance river so step transitions happen exactly at transaction dates (curveBasis didn't pass through data points, causing waterfall shapes to disconnect from the river edges) - Update hardcoded USD estimates to current CoinGecko prices (2026-03-25) - Add SAFE, COW, ENS, LDO, BAL to the price estimate table - Fix BigInt→Number precision for large token balances (>2^53 wei) in both price-feed enrichment and transfer value parsing Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/data-transform.ts | 51 ++++++++++++++++----------- modules/rwallet/lib/price-feed.ts | 6 +++- modules/rwallet/lib/wallet-viz.ts | 4 ++- 3 files changed, 39 insertions(+), 22 deletions(-) diff --git a/modules/rwallet/lib/data-transform.ts b/modules/rwallet/lib/data-transform.ts index 712f375..a39cd73 100644 --- a/modules/rwallet/lib/data-transform.ts +++ b/modules/rwallet/lib/data-transform.ts @@ -106,14 +106,24 @@ export function txExplorerLink(txHash: string, chainId: string): string { return `${base}/tx/${txHash}`; } +/** Convert raw wei string to human-readable number, preserving precision for large values */ +function weiToHuman(raw: string, decimals: number): number { + try { + const wei = BigInt(raw || "0"); + const divisor = 10n ** BigInt(decimals); + return Number(wei / divisor) + Number(wei % divisor) / Number(divisor); + } catch { + return parseFloat(raw || "0") / Math.pow(10, decimals); + } +} + export function getTransferValue(transfer: any): number { if (transfer.type === "ERC20_TRANSFER" || transfer.transferType === "ERC20_TRANSFER") { const decimals = transfer.tokenInfo?.decimals || transfer.token?.decimals || 18; - const raw = transfer.value || "0"; - return parseFloat(raw) / Math.pow(10, decimals); + return weiToHuman(transfer.value || "0", decimals); } if (transfer.type === "ETHER_TRANSFER" || transfer.transferType === "ETHER_TRANSFER") { - return parseFloat(transfer.value || "0") / 1e18; + return weiToHuman(transfer.value || "0", 18); } return 0; } @@ -134,14 +144,15 @@ const STABLECOINS = new Set([ "GHO", "PYUSD", "DOLA", "Yield-USD", "yUSD", ]); -// Approximate USD prices for major non-stablecoin tokens (updated periodically) +// Approximate USD prices for major non-stablecoin tokens (updated 2026-03-25 from CoinGecko) const NATIVE_APPROX_USD: Record = { - ETH: 2500, WETH: 2500, stETH: 2500, cbETH: 2500, rETH: 2800, wstETH: 2900, - MATIC: 0.40, POL: 0.40, WMATIC: 0.40, + ETH: 2165, WETH: 2165, stETH: 2165, cbETH: 2165, rETH: 2500, wstETH: 2665, + MATIC: 0.20, POL: 0.20, WMATIC: 0.20, BNB: 600, WBNB: 600, - AVAX: 35, WAVAX: 35, + AVAX: 20, WAVAX: 20, xDAI: 1, WXDAI: 1, - CELO: 0.50, GNO: 250, + CELO: 0.30, GNO: 129, + SAFE: 0.10, COW: 0.21, ENS: 6.11, LDO: 0.30, BAL: 0.15, }; export function estimateUSD(value: number, symbol: string): number | null { @@ -217,7 +228,7 @@ export function transformToTimelineData( // Fallback: parse from dataDecoded or direct value if (txTransfers.length === 0) { if (tx.value && tx.value !== "0") { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; txTransfers.push({ to: tx.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); } @@ -226,7 +237,7 @@ export function transformToTimelineData( const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; - const val = parseFloat(rawVal) / 1e18; + const val = weiToHuman(rawVal, 18); txTransfers.push({ to, value: val, symbol: "Token", usd: null }); } @@ -235,14 +246,14 @@ export function transformToTimelineData( if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0") { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; txTransfers.push({ to: inner.to, value: val, symbol: sym, usd: estimateUSD(val, sym) }); } if (inner.dataDecoded?.method === "transfer") { const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; - const val2 = parseFloat(raw2) / 1e18; + const val2 = weiToHuman(raw2, 18); txTransfers.push({ to: to2, value: val2, symbol: "Token", usd: null }); } } @@ -334,7 +345,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const txLabel = tx.transfers?.[0]?._toLabel; if (tx.value && tx.value !== "0" && tx.to) { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; const key = `${tx.to.toLowerCase()}:${sym}`; const existing = outflowAgg.get(key) || { to: tx.to, value: 0, symbol: sym, label: txLabel }; @@ -347,7 +358,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; if (to) { - const val = parseFloat(rawVal) / 1e18; + const val = weiToHuman(rawVal, 18); const key = `${to.toLowerCase()}:Token`; const existing = outflowAgg.get(key) || { to, value: 0, symbol: "Token" }; existing.value += val; @@ -360,7 +371,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0" && inner.to) { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = (chainId && CHAIN_NATIVE_SYMBOL[chainId]) || "ETH"; const key = `${inner.to.toLowerCase()}:${sym}`; const existing = outflowAgg.get(key) || { to: inner.to, value: 0, symbol: sym }; @@ -371,7 +382,7 @@ export function transformToSankeyData(chainData: any, safeAddress: string, chain const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; if (to2) { - const val2 = parseFloat(raw2) / 1e18; + const val2 = weiToHuman(raw2, 18); const key = `${to2.toLowerCase()}:Token`; const existing = outflowAgg.get(key) || { to: to2, value: 0, symbol: "Token" }; existing.value += val2; @@ -513,7 +524,7 @@ export function transformToMultichainData( const txLabel = tx.transfers?.[0]?._toLabel; if (tx.value && tx.value !== "0" && tx.to) { - const val = parseFloat(tx.value) / 1e18; + const val = weiToHuman(tx.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; outTransfers.push({ to: tx.to, value: val, symbol: sym, label: txLabel }); } @@ -522,7 +533,7 @@ export function transformToMultichainData( const params = tx.dataDecoded.parameters || []; const to = params.find((p: any) => p.name === "to")?.value; const rawVal = params.find((p: any) => p.name === "value")?.value || "0"; - if (to) outTransfers.push({ to, value: parseFloat(rawVal) / 1e18, symbol: "Token" }); + if (to) outTransfers.push({ to, value: weiToHuman(rawVal, 18), symbol: "Token" }); } if (tx.dataDecoded?.method === "multiSend") { @@ -530,14 +541,14 @@ export function transformToMultichainData( if (txsParam?.valueDecoded) { for (const inner of txsParam.valueDecoded) { if (inner.value && inner.value !== "0" && inner.to) { - const val = parseFloat(inner.value) / 1e18; + const val = weiToHuman(inner.value, 18); const sym = CHAIN_NATIVE_SYMBOL[chainId] || "ETH"; outTransfers.push({ to: inner.to, value: val, symbol: sym }); } if (inner.dataDecoded?.method === "transfer") { const to2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "to")?.value; const raw2 = inner.dataDecoded.parameters?.find((p: any) => p.name === "value")?.value || "0"; - if (to2) outTransfers.push({ to: to2, value: parseFloat(raw2) / 1e18, symbol: "Token" }); + if (to2) outTransfers.push({ to: to2, value: weiToHuman(raw2, 18), symbol: "Token" }); } } } diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index 0f888e5..9c12703 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -213,7 +213,11 @@ export async function enrichWithPrices( if (price === 0) return b; const decimals = b.token?.decimals ?? 18; - const balHuman = Number(balWei) / Math.pow(10, decimals); + // Split BigInt to preserve precision for values > 2^53 + const divisor = 10n ** BigInt(decimals); + const intPart = balWei / divisor; + const fracPart = balWei % divisor; + const balHuman = Number(intPart) + Number(fracPart) / Number(divisor); const fiatValue = balHuman * price; return { diff --git a/modules/rwallet/lib/wallet-viz.ts b/modules/rwallet/lib/wallet-viz.ts index 8983e15..86ba8f9 100644 --- a/modules/rwallet/lib/wallet-viz.ts +++ b/modules/rwallet/lib/wallet-viz.ts @@ -231,7 +231,9 @@ export function renderTimeline( function drawContent(scale: any) { contentGroup.selectAll("*").remove(); - const smoothCurve = d3.curveBasis; + // curveStepAfter: balance is constant between txs, steps at each tx. + // This ensures the river edges align exactly with the waterfall shapes. + const smoothCurve = d3.curveStepAfter; // River glow contentGroup.append("path").datum(balanceData).attr("fill", "rgba(0,212,255,0.08)")