From afe296fab2b12a88cb41d22eb64ead01b72c1b9f Mon Sep 17 00:00:00 2001 From: Nexory Date: Tue, 9 Jun 2026 22:30:39 +0200 Subject: [PATCH] fix(scripts): scale token amounts without a floating-point intermediary The bridge and mint CLI handlers scaled the user-entered amount with `BigInt(Math.floor(amount * 10 ** decimals))`, where `amount` is a `parseFloat`-ed number. Evaluating `amount * 10 ** decimals` in IEEE-754 double precision loses accuracy: `1.005 * 1e9` is `1004999999.9999999`, so `Math.floor` produces `1_004_999_999` lamports instead of the intended `1_005_000_000`. `1.000001` at 6 decimals drops the trailing unit (`1_000_000` instead of `1_000_001`), and 18-decimal (wei) amounts exceed `Number.MAX_SAFE_INTEGER` and lose arbitrary trailing precision. Add a small `parseTokenAmount(value, decimals)` helper backed by viem's `parseUnits`, which parses the decimal string directly with no float intermediary, and use it in all six call sites: - bridge-sol, bridge-sol-with-bc, bridge-spl, bridge-wrapped-token (9 or mint-decimals) - bridge-call (18, ETH -> wei) - spl/mint (mint decimals) The amount/value Zod fields now keep the validated string instead of transforming to a float, so the raw decimal input reaches parseUnits intact. Logging is unchanged (the field is still a string). Verified locally: - bun test src/internal/amount.test.ts : 3 pass (documents the legacy float loss and pins the corrected output) - tsc --noEmit : 0 errors across the scripts package --- .../solana-to-base/bridge-call.handler.ts | 18 ++++++++----- .../bridge-sol-with-bc.handler.ts | 16 ++++++----- .../solana-to-base/bridge-sol.handler.ts | 16 ++++++----- .../solana-to-base/bridge-spl.handler.ts | 20 +++++++------- .../bridge-wrapped-token.handler.ts | 20 +++++++------- scripts/src/commands/sol/spl/mint.handler.ts | 22 ++++++++------- scripts/src/internal/amount.test.ts | 27 +++++++++++++++++++ scripts/src/internal/amount.ts | 19 +++++++++++++ 8 files changed, 113 insertions(+), 45 deletions(-) create mode 100644 scripts/src/internal/amount.test.ts create mode 100644 scripts/src/internal/amount.ts diff --git a/scripts/src/commands/sol/bridge/solana-to-base/bridge-call.handler.ts b/scripts/src/commands/sol/bridge/solana-to-base/bridge-call.handler.ts index f63ab75..9427a43 100644 --- a/scripts/src/commands/sol/bridge/solana-to-base/bridge-call.handler.ts +++ b/scripts/src/commands/sol/bridge/solana-to-base/bridge-call.handler.ts @@ -14,6 +14,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -42,11 +43,16 @@ export const argsSchema = z.object({ ]), value: z .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val >= 0, { - message: "Value must be a non-negative number", - }) - .default(0), + .refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n >= 0; + }, + { + message: "Value must be a non-negative number", + }, + ) + .default("0"), data: z .union([ z.literal("increment"), @@ -114,7 +120,7 @@ export async function handleBridgeCall(args: Args): Promise { call: { ty: CallType.Call, to: toBytes(targetAddress), - value: BigInt(Math.floor(args.value * 1e18)), // Convert ETH to wei + value: parseTokenAmount(args.value, 18), // Convert ETH to wei data: Buffer.from(callData.slice(2), "hex"), // Remove 0x prefix }, }, diff --git a/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol-with-bc.handler.ts b/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol-with-bc.handler.ts index 7f5eee5..6007dec 100644 --- a/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol-with-bc.handler.ts +++ b/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol-with-bc.handler.ts @@ -19,6 +19,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { FLYWHEEL_ABI } from "@internal/base/abi"; import { buildAndSendTransaction, @@ -46,12 +47,15 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { + amount: z.string().refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n > 0; + }, + { message: "Amount must be a positive number", - }), + }, + ), builderCode: z .string() .regex(/^0x[a-fA-F0-9]{64}$/, { @@ -97,7 +101,7 @@ export async function handleBridgeSolWithBc(args: Args): Promise { logger.info(`Sol Vault: ${solVaultAddress}`); // Calculate scaled amount (amount * 10^decimals) - const scaledAmount = BigInt(Math.floor(args.amount * Math.pow(10, 9))); + const scaledAmount = parseTokenAmount(args.amount, 9); logger.info(`Amount: ${args.amount}`); logger.info(`Scaled amount: ${scaledAmount}`); diff --git a/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol.handler.ts b/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol.handler.ts index f02f1ac..d2afc01 100644 --- a/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol.handler.ts +++ b/scripts/src/commands/sol/bridge/solana-to-base/bridge-sol.handler.ts @@ -10,6 +10,7 @@ import { toBytes, isAddress as isEvmAddress } from "viem"; import { fetchBridge, getBridgeSolInstruction } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -36,12 +37,15 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { + amount: z.string().refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n > 0; + }, + { message: "Amount must be a positive number", - }), + }, + ), payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -74,7 +78,7 @@ export async function handleBridgeSol(args: Args): Promise { logger.info(`Sol Vault: ${solVaultAddress}`); // Calculate scaled amount (amount * 10^decimals) - const scaledAmount = BigInt(Math.floor(args.amount * Math.pow(10, 9))); + const scaledAmount = parseTokenAmount(args.amount, 9); logger.info(`Amount: ${args.amount}`); logger.info(`Scaled amount: ${scaledAmount}`); diff --git a/scripts/src/commands/sol/bridge/solana-to-base/bridge-spl.handler.ts b/scripts/src/commands/sol/bridge/solana-to-base/bridge-spl.handler.ts index 3d723b7..a5487da 100644 --- a/scripts/src/commands/sol/bridge/solana-to-base/bridge-spl.handler.ts +++ b/scripts/src/commands/sol/bridge/solana-to-base/bridge-spl.handler.ts @@ -22,6 +22,7 @@ import { toBytes, isAddress as isEvmAddress } from "viem"; import { fetchBridge, getBridgeSplInstruction } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -61,12 +62,15 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { + amount: z.string().refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n > 0; + }, + { message: "Amount must be a positive number", - }), + }, + ), payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -108,10 +112,8 @@ export async function handleBridgeSpl(args: Args): Promise { const remoteTokenBytes = toBytes(remoteTokenAddress); const mintBytes = getBase58Encoder().encode(mintAddress); - // Calculate scaled amount (amount * 10^decimals) - const scaledAmount = BigInt( - Math.floor(args.amount * Math.pow(10, maybeMint.data.decimals)) - ); + // Scale amount to the smallest unit using string-based decimal parsing. + const scaledAmount = parseTokenAmount(args.amount, maybeMint.data.decimals); logger.info(`Amount: ${args.amount}`); logger.info(`Decimals: ${maybeMint.data.decimals}`); logger.info(`Scaled amount: ${scaledAmount}`); diff --git a/scripts/src/commands/sol/bridge/solana-to-base/bridge-wrapped-token.handler.ts b/scripts/src/commands/sol/bridge/solana-to-base/bridge-wrapped-token.handler.ts index 2becbab..9604566 100644 --- a/scripts/src/commands/sol/bridge/solana-to-base/bridge-wrapped-token.handler.ts +++ b/scripts/src/commands/sol/bridge/solana-to-base/bridge-wrapped-token.handler.ts @@ -23,6 +23,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -59,12 +60,15 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { + amount: z.string().refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n > 0; + }, + { message: "Amount must be a positive number", - }), + }, + ), payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -105,10 +109,8 @@ export async function handleBridgeWrappedToken(args: Args): Promise { }); logger.info(`Bridge account: ${bridgeAccountAddress}`); - // Calculate scaled amount (amount * 10^decimals) - const scaledAmount = BigInt( - Math.floor(args.amount * Math.pow(10, maybeMint.data.decimals)) - ); + // Scale amount to the smallest unit using string-based decimal parsing. + const scaledAmount = parseTokenAmount(args.amount, maybeMint.data.decimals); logger.info(`Amount: ${args.amount}`); logger.info(`Decimals: ${maybeMint.data.decimals}`); logger.info(`Scaled amount: ${scaledAmount}`); diff --git a/scripts/src/commands/sol/spl/mint.handler.ts b/scripts/src/commands/sol/spl/mint.handler.ts index 1126b30..4d87e9c 100644 --- a/scripts/src/commands/sol/spl/mint.handler.ts +++ b/scripts/src/commands/sol/spl/mint.handler.ts @@ -10,6 +10,7 @@ import { } from "@solana-program/token"; import { logger } from "@internal/logger"; +import { parseTokenAmount } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -31,11 +32,16 @@ export const argsSchema = z.object({ .default("config"), amount: z .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { - message: "Amount must be a positive number", - }) - .default(100), + .refine( + (val) => { + const n = Number.parseFloat(val); + return !Number.isNaN(n) && n > 0; + }, + { + message: "Amount must be a positive number", + }, + ) + .default("100"), mintAuthorityKp: z .union([z.literal("config"), z.string().brand<"mintAuthorityKp">()]) .default("config"), @@ -72,10 +78,8 @@ export async function handleMint(args: Args): Promise { const recipientAddress = await resolveRecipient(args.to, rpc, maybeMint); logger.info(`Recipient: ${recipientAddress}`); - // Calculate scaled amount (amount * 10^decimals) - const scaledAmount = BigInt( - Math.floor(args.amount * Math.pow(10, mint.decimals)) - ); + // Scale amount to the smallest unit using string-based decimal parsing. + const scaledAmount = parseTokenAmount(args.amount, mint.decimals); logger.info(`Amount: ${args.amount}`); logger.info(`Decimals: ${mint.decimals}`); logger.info(`Scaled amount: ${scaledAmount}`); diff --git a/scripts/src/internal/amount.test.ts b/scripts/src/internal/amount.test.ts new file mode 100644 index 0000000..09434e0 --- /dev/null +++ b/scripts/src/internal/amount.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { parseTokenAmount } from "./amount"; + +// Documents the precision loss in the previous inline scaling approach that +// this util replaces: BigInt(Math.floor(parseFloat(value) * 10 ** decimals)). +function legacyInlineScale(value: string, decimals: number): bigint { + return BigInt(Math.floor(parseFloat(value) * 10 ** decimals)); +} + +test("legacy inline float scaling loses precision (the bug being fixed)", () => { + // 1.005 SOL (9 decimals): intended 1_005_000_000 lamports + expect(legacyInlineScale("1.005", 9)).toBe(1_004_999_999n); // off by 1 + // 1.000001 USDC (6 decimals): intended 1_000_001 + expect(legacyInlineScale("1.000001", 6)).toBe(1_000_000n); // unit dropped +}); + +test("parseTokenAmount scales decimal strings exactly", () => { + expect(parseTokenAmount("1.005", 9)).toBe(1_005_000_000n); + expect(parseTokenAmount("1.000001", 6)).toBe(1_000_001n); + // 18-decimal (wei) amounts exceed Number.MAX_SAFE_INTEGER; still exact + expect(parseTokenAmount("1.005", 18)).toBe(1_005_000_000_000_000_000n); +}); + +test("parseTokenAmount matches legacy output for inputs that had no float error", () => { + expect(parseTokenAmount("0.1", 9)).toBe(legacyInlineScale("0.1", 9)); + expect(parseTokenAmount("2", 9)).toBe(legacyInlineScale("2", 9)); +}); diff --git a/scripts/src/internal/amount.ts b/scripts/src/internal/amount.ts new file mode 100644 index 0000000..39daa0d --- /dev/null +++ b/scripts/src/internal/amount.ts @@ -0,0 +1,19 @@ +import { parseUnits } from "viem"; + +/** + * Scales a human-entered decimal amount string to its smallest-unit bigint + * representation without going through a floating-point intermediary. + * + * The previous inline approach `BigInt(Math.floor(parseFloat(value) * 10 ** decimals))` + * loses precision because `parseFloat(value) * 10 ** decimals` is evaluated in + * IEEE-754 double precision. For example `1.005 * 1e9` evaluates to + * `1004999999.9999999`, so `Math.floor` yields `1_004_999_999` instead of the + * intended `1_005_000_000`. For 18-decimal (wei) amounts the magnitude exceeds + * `Number.MAX_SAFE_INTEGER`, losing arbitrary trailing precision. + * + * `viem`'s `parseUnits` parses the decimal string directly, so the result is + * always exact for any valid input. + */ +export function parseTokenAmount(value: string, decimals: number): bigint { + return parseUnits(value, decimals); +}