Skip to content

Commit 5d3d868

Browse files
Th0rgalclaude
andauthored
refactor(core): extract shared formatTokenAmount + add ERC-1155 tests (#156)
The token amount formatter was duplicated between event-decoder.ts (formatAmount) and slot-decoder.ts (formatTokenAmount) with subtle differences in null-decimal handling. Extract the canonical version into a shared format.ts module that both files now import. Also adds: - 11 unit tests for formatTokenAmount covering zero, dust amounts, large numbers, fractional truncation, and null-decimal fallback - 3 tests for ERC-1155 TransferSingle and TransferBatch decoding (previously untested) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8231b0f commit 5d3d868

5 files changed

Lines changed: 233 additions & 54 deletions

File tree

packages/core/src/lib/simulation/__tests__/event-decoder.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ const TRANSFER_TOPIC =
1515
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
1616
const APPROVAL_TOPIC =
1717
"0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
18+
const TRANSFER_SINGLE_TOPIC =
19+
"0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62";
20+
const TRANSFER_BATCH_TOPIC =
21+
"0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb";
1822
const DEPOSIT_TOPIC =
1923
"0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c";
2024
const WITHDRAWAL_TOPIC =
@@ -221,6 +225,113 @@ describe("decodeSimulationEvents", () => {
221225
});
222226
});
223227

228+
// ── ERC-1155 ──────────────────────────────────────────────────────
229+
230+
describe("ERC-1155 TransferSingle", () => {
231+
const OPERATOR = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
232+
const NFT_CONTRACT = "0x0000000000000000000000000000000000000099";
233+
234+
it("decodes a TransferSingle (receive)", () => {
235+
const tokenId = 7n;
236+
const amount = 3n;
237+
// data = abi.encode(uint256 id, uint256 value)
238+
const data = "0x" + tokenId.toString(16).padStart(64, "0") + amount.toString(16).padStart(64, "0");
239+
const logs: SimulationLog[] = [
240+
{
241+
address: NFT_CONTRACT,
242+
topics: [
243+
TRANSFER_SINGLE_TOPIC,
244+
pad32(OPERATOR),
245+
pad32("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
246+
pad32(SAFE),
247+
],
248+
data,
249+
},
250+
];
251+
252+
const events = decodeSimulationEvents(logs, SAFE);
253+
expect(events).toHaveLength(1);
254+
expect(events[0].kind).toBe("erc1155-transfer");
255+
expect(events[0].tokenId).toBe("7");
256+
expect(events[0].amountRaw).toBe("3");
257+
expect(events[0].amountFormatted).toContain("3x");
258+
expect(events[0].amountFormatted).toContain("#7");
259+
expect(events[0].direction).toBe("receive");
260+
});
261+
});
262+
263+
describe("ERC-1155 TransferBatch", () => {
264+
const OPERATOR = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
265+
const NFT_CONTRACT = "0x0000000000000000000000000000000000000099";
266+
267+
it("decodes a batch with 2 items", () => {
268+
// ABI layout:
269+
// word 0: offset to ids array (0x40 = 64 bytes)
270+
// word 1: offset to vals array (0xa0 = 160 bytes)
271+
// word 2: ids length (2)
272+
// word 3: ids[0] = 10
273+
// word 4: ids[1] = 20
274+
// word 5: vals length (2)
275+
// word 6: vals[0] = 5
276+
// word 7: vals[1] = 1
277+
const words = [
278+
64n, // offset to ids
279+
160n, // offset to vals
280+
2n, // ids length
281+
10n, // ids[0]
282+
20n, // ids[1]
283+
2n, // vals length
284+
5n, // vals[0]
285+
1n, // vals[1]
286+
];
287+
const data = "0x" + words.map((w) => w.toString(16).padStart(64, "0")).join("");
288+
const logs: SimulationLog[] = [
289+
{
290+
address: NFT_CONTRACT,
291+
topics: [
292+
TRANSFER_BATCH_TOPIC,
293+
pad32(OPERATOR),
294+
pad32(SAFE),
295+
pad32("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
296+
],
297+
data,
298+
},
299+
];
300+
301+
const events = decodeSimulationEvents(logs, SAFE);
302+
expect(events).toHaveLength(2);
303+
304+
expect(events[0].kind).toBe("erc1155-transfer");
305+
expect(events[0].tokenId).toBe("10");
306+
expect(events[0].amountRaw).toBe("5");
307+
expect(events[0].direction).toBe("send");
308+
309+
expect(events[1].kind).toBe("erc1155-transfer");
310+
expect(events[1].tokenId).toBe("20");
311+
expect(events[1].amountRaw).toBe("1");
312+
expect(events[1].direction).toBe("send");
313+
});
314+
315+
it("skips malformed batch data gracefully", () => {
316+
// Too-short data (< 5 words = 320 hex chars)
317+
const logs: SimulationLog[] = [
318+
{
319+
address: NFT_CONTRACT,
320+
topics: [
321+
TRANSFER_BATCH_TOPIC,
322+
pad32(OPERATOR),
323+
pad32(SAFE),
324+
pad32("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"),
325+
],
326+
data: "0x" + "00".repeat(64),
327+
},
328+
];
329+
330+
const events = decodeSimulationEvents(logs, SAFE);
331+
expect(events).toHaveLength(0);
332+
});
333+
});
334+
224335
// ── decodeNativeTransfers ──────────────────────────────────────────
225336

226337
describe("decodeNativeTransfers", () => {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, it, expect } from "vitest";
2+
import { formatTokenAmount } from "../format";
3+
4+
describe("formatTokenAmount", () => {
5+
describe("known decimals", () => {
6+
it("formats zero", () => {
7+
expect(formatTokenAmount(0n, 18, "WETH")).toBe("0 WETH");
8+
expect(formatTokenAmount(0n, 6, null)).toBe("0");
9+
});
10+
11+
it("formats whole amounts with thousands separators", () => {
12+
expect(formatTokenAmount(5000n * 10n ** 18n, 18, "WETH")).toContain("5,000");
13+
expect(formatTokenAmount(5000n * 10n ** 18n, 18, "WETH")).toContain("WETH");
14+
});
15+
16+
it("formats fractional amounts up to 4 decimals", () => {
17+
// 1.5 USDC = 1_500_000 raw (6 decimals)
18+
expect(formatTokenAmount(1_500_000n, 6, "USDC")).toBe("1.5 USDC");
19+
});
20+
21+
it("strips trailing fractional zeros", () => {
22+
// 2.10 DAI → should show 2.1, not 2.10 or 2.1000
23+
expect(formatTokenAmount(2_100_000_000_000_000_000n, 18, "DAI")).toBe("2.1 DAI");
24+
});
25+
26+
it("shows <0.0001 for dust amounts", () => {
27+
// 1 wei of WETH = too small for 4 decimal places
28+
expect(formatTokenAmount(1n, 18, "WETH")).toBe("<0.0001 WETH");
29+
// 100 wei (still under 0.0001 WETH = 10^14 wei)
30+
expect(formatTokenAmount(100n, 18, null)).toBe("<0.0001");
31+
});
32+
33+
it("formats large amounts correctly", () => {
34+
// 1,234,567.89 DAI
35+
const raw = 1_234_567_890_000_000_000_000_000n;
36+
const result = formatTokenAmount(raw, 18, "DAI");
37+
expect(result).toContain("1,234,567");
38+
expect(result).toContain("DAI");
39+
});
40+
41+
it("works without a symbol", () => {
42+
expect(formatTokenAmount(1_000_000n, 6, null)).toBe("1");
43+
expect(formatTokenAmount(1_500_000n, 6, null)).toBe("1.5");
44+
});
45+
46+
it("truncates to 4 decimal places (no rounding)", () => {
47+
// 1.123456789 with 9 decimals → show 1.1234
48+
expect(formatTokenAmount(1_123_456_789n, 9, "TOK")).toBe("1.1234 TOK");
49+
});
50+
});
51+
52+
describe("null decimals (unknown token)", () => {
53+
it("returns raw bigint as string", () => {
54+
expect(formatTokenAmount(12345n, null, null)).toBe("12345");
55+
});
56+
57+
it("appends symbol when available", () => {
58+
expect(formatTokenAmount(42n, null, "???")).toBe("42 ???");
59+
});
60+
61+
it("handles zero with null decimals", () => {
62+
expect(formatTokenAmount(0n, null, "X")).toBe("0 X");
63+
});
64+
});
65+
});

packages/core/src/lib/simulation/event-decoder.ts

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313

1414
import type { SimulationLog, NativeTransfer } from "../types";
15+
import { formatTokenAmount } from "./format";
1516

1617
// ── Event signatures (keccak256 hashes) ──────────────────────────────
1718

@@ -152,29 +153,9 @@ function hexToDecimal(hex: string): string {
152153
return BigInt("0x" + clean).toString();
153154
}
154155

155-
/** Format a raw token amount with decimals. */
156+
/** Format a raw token amount with decimals (delegates to shared formatter). */
156157
function formatAmount(raw: string, decimals: number, symbol: string | null): string {
157-
const value = BigInt(raw);
158-
if (value === 0n) return symbol ? `0 ${symbol}` : "0";
159-
160-
const divisor = BigInt(10) ** BigInt(decimals);
161-
const whole = value / divisor;
162-
const remainder = value % divisor;
163-
164-
// Use commas for thousands
165-
const wholeStr = whole.toLocaleString("en-US");
166-
const fractional = remainder.toString().padStart(decimals, "0").slice(0, 4).replace(/0+$/, "");
167-
168-
let numStr: string;
169-
if (fractional.length > 0) {
170-
numStr = `${wholeStr}.${fractional}`;
171-
} else if (whole === 0n && remainder > 0n) {
172-
// Non-zero amount too small for 4 decimal places (e.g. 1 wei of WETH)
173-
numStr = "<0.0001";
174-
} else {
175-
numStr = wholeStr;
176-
}
177-
return symbol ? `${numStr} ${symbol}` : numStr;
158+
return formatTokenAmount(BigInt(raw), decimals, symbol);
178159
}
179160

180161
/** Look up token metadata. */
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* Shared token amount formatting utilities.
3+
*
4+
* Used by both the event decoder (for display of decoded events) and
5+
* the slot decoder (for proven balance/allowance deltas). Keeping
6+
* this in one place prevents drift between the two formatters.
7+
*/
8+
9+
/**
10+
* Format a token amount for human-readable display.
11+
*
12+
* - Adds thousands separators (e.g. "1,234,567")
13+
* - Shows up to 4 fractional digits, stripping trailing zeros
14+
* - Shows "<0.0001" for dust amounts that round to zero at 4 decimals
15+
* - Falls back to raw string when `decimals` is null (unknown token)
16+
*
17+
* @param raw - Raw token amount as a bigint.
18+
* @param decimals - Token decimals, or null for unknown tokens.
19+
* @param symbol - Token symbol for display, or null.
20+
*/
21+
export function formatTokenAmount(
22+
raw: bigint,
23+
decimals: number | null,
24+
symbol: string | null,
25+
): string {
26+
if (decimals == null) {
27+
const str = raw.toString();
28+
return symbol ? `${str} ${symbol}` : str;
29+
}
30+
31+
if (raw === 0n) return symbol ? `0 ${symbol}` : "0";
32+
33+
const divisor = 10n ** BigInt(decimals);
34+
const whole = raw / divisor;
35+
const remainder = (raw < 0n ? -raw : raw) % divisor;
36+
37+
const wholeStr = whole.toLocaleString("en-US");
38+
const fractional = remainder
39+
.toString()
40+
.padStart(decimals, "0")
41+
.slice(0, 4)
42+
.replace(/0+$/, "");
43+
44+
let numStr: string;
45+
if (fractional.length > 0) {
46+
numStr = `${wholeStr}.${fractional}`;
47+
} else if (whole === 0n && remainder > 0n) {
48+
numStr = "<0.0001";
49+
} else {
50+
numStr = wholeStr;
51+
}
52+
return symbol ? `${numStr} ${symbol}` : numStr;
53+
}

packages/core/src/lib/simulation/slot-decoder.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from "viem";
2424
import type { StateDiffEntry } from "../types";
2525
import type { DecodedEvent } from "./event-decoder";
26+
import { formatTokenAmount } from "./format";
2627

2728
// ── ERC-20 storage layout definitions ──────────────────────────────
2829

@@ -143,38 +144,6 @@ function hexToUint256(hex: string): bigint {
143144
return BigInt(hex);
144145
}
145146

146-
function formatTokenAmount(
147-
raw: bigint,
148-
decimals: number | null,
149-
symbol: string | null,
150-
): string {
151-
if (decimals == null) {
152-
const str = raw.toString();
153-
return symbol ? `${str} ${symbol}` : str;
154-
}
155-
156-
const divisor = 10n ** BigInt(decimals);
157-
const whole = raw / divisor;
158-
const remainder = (raw < 0n ? -raw : raw) % divisor;
159-
160-
const wholeStr = whole.toLocaleString("en-US");
161-
const fractional = remainder
162-
.toString()
163-
.padStart(decimals, "0")
164-
.slice(0, 4)
165-
.replace(/0+$/, "");
166-
167-
let numStr: string;
168-
if (fractional.length > 0) {
169-
numStr = `${wholeStr}.${fractional}`;
170-
} else if (whole === 0n && remainder > 0n) {
171-
numStr = "<0.0001";
172-
} else {
173-
numStr = wholeStr;
174-
}
175-
return symbol ? `${numStr} ${symbol}` : numStr;
176-
}
177-
178147
function formatDelta(
179148
before: bigint,
180149
after: bigint,

0 commit comments

Comments
 (0)