fix(rwallet): align timeline inflows/outflows with balance changes

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 17:36:10 -07:00
parent ff09d49127
commit 8ea537525a
3 changed files with 39 additions and 22 deletions

View File

@ -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<string, number> = {
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" });
}
}
}

View File

@ -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 {

View File

@ -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)")