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..65f53d3 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, nonNegativeAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -40,13 +41,7 @@ export const argsSchema = z.object({ z.literal("counter"), z.string().startsWith("0x", "Address must start with 0x").brand<"to">(), ]), - value: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val >= 0, { - message: "Value must be a non-negative number", - }) - .default(0), + value: nonNegativeAmountSchema, data: z .union([ z.literal("increment"), @@ -114,7 +109,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..0f00957 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, positiveAmountSchema } from "@internal/amount"; import { FLYWHEEL_ABI } from "@internal/base/abi"; import { buildAndSendTransaction, @@ -46,12 +47,7 @@ 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, { - message: "Amount must be a positive number", - }), + amount: positiveAmountSchema, builderCode: z .string() .regex(/^0x[a-fA-F0-9]{64}$/, { @@ -97,7 +93,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..91c7421 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, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -36,12 +37,7 @@ 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, { - message: "Amount must be a positive number", - }), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -74,7 +70,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..14b0598 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, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -61,12 +62,7 @@ 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, { - message: "Amount must be a positive number", - }), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -108,10 +104,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..face9c0 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, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -59,12 +60,7 @@ 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, { - message: "Amount must be a positive number", - }), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), @@ -105,10 +101,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..f59c41e 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, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -29,13 +30,7 @@ export const argsSchema = z.object({ to: z .union([z.literal("config"), z.string().brand<"to">()]) .default("config"), - amount: z - .string() - .transform((val) => parseFloat(val)) - .refine((val) => !isNaN(val) && val > 0, { - message: "Amount must be a positive number", - }) - .default(100), + amount: positiveAmountSchema.default("100"), mintAuthorityKp: z .union([z.literal("config"), z.string().brand<"mintAuthorityKp">()]) .default("config"), @@ -72,10 +67,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..aaa0583 --- /dev/null +++ b/scripts/src/internal/amount.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { + nonNegativeAmountSchema, + parseTokenAmount, + positiveAmountSchema, +} 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)); +}); + +test("parseTokenAmount rounds over-precision half-up (parseUnits, not floor)", () => { + // more fractional digits than the token supports: parseUnits rounds half-up, + // unlike the previous Math.floor which truncated + expect(parseTokenAmount("1.0000000006", 9)).toBe(1_000_000_001n); + expect(parseTokenAmount("1.0000000004", 9)).toBe(1_000_000_000n); +}); + +test("positiveAmountSchema rejects inputs parseUnits would throw on", () => { + // these all pass a lenient parseFloat check but are rejected by parseUnits; + // validating with the same grammar surfaces a clear CLI error instead + for (const bad of ["1e-3", "1abc", "+1", "1_000", "Infinity", " 1.5 ", "", "0", "-1"]) { + expect(positiveAmountSchema.safeParse(bad).success).toBe(false); + } +}); + +test("positiveAmountSchema accepts plain positive decimal strings", () => { + for (const good of ["1", "1.5", "100", "0.001", "0.000000001"]) { + expect(positiveAmountSchema.safeParse(good).success).toBe(true); + } +}); + +test("nonNegativeAmountSchema accepts 0, defaults to \"0\", rejects negatives", () => { + expect(nonNegativeAmountSchema.safeParse("0").success).toBe(true); + expect(nonNegativeAmountSchema.parse(undefined)).toBe("0"); + expect(nonNegativeAmountSchema.safeParse("-1").success).toBe(false); + expect(nonNegativeAmountSchema.safeParse("1e18").success).toBe(false); +}); diff --git a/scripts/src/internal/amount.ts b/scripts/src/internal/amount.ts new file mode 100644 index 0000000..7ddb5ed --- /dev/null +++ b/scripts/src/internal/amount.ts @@ -0,0 +1,43 @@ +import { parseUnits } from "viem"; +import { z } from "zod"; + +// viem's `parseUnits` accepts a plain decimal string only: digits with at most one +// decimal point, and no sign, exponent, digit separators, or surrounding whitespace. +// Validate the CLI input with the same grammar so a malformed amount fails with a +// clear message here instead of throwing `InvalidDecimalNumberError` deep inside the +// handler once it reaches `parseTokenAmount` (e.g. "1e-3", "1_000", "+1", " 1.5 "). +const DECIMAL_STRING_RE = /^\d+(\.\d+)?$/; + +/** A required, strictly positive token amount string (for example "1.5"). */ +export const positiveAmountSchema = z.string().refine( + (value) => DECIMAL_STRING_RE.test(value) && Number(value) > 0, + { message: 'Amount must be a positive decimal number (for example "1.5")' }, +); + +/** A non-negative value string (for example an ETH `value`), defaulting to "0". */ +export const nonNegativeAmountSchema = z + .string() + .refine((value) => DECIMAL_STRING_RE.test(value) && Number(value) >= 0, { + message: 'Value must be a non-negative decimal number (for example "1.5")', + }) + .default("0"); + +/** + * 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 input within the token's precision. Inputs with more + * fractional digits than `decimals` are rounded half-up (unlike the previous + * `Math.floor`, which truncated). + */ +export function parseTokenAmount(value: string, decimals: number): bigint { + return parseUnits(value, decimals); +}