From afe296fab2b12a88cb41d22eb64ead01b72c1b9f Mon Sep 17 00:00:00 2001 From: Nexory Date: Tue, 9 Jun 2026 22:30:39 +0200 Subject: [PATCH 1/2] 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); +} From 3aaed9276e2a3158e29b48ce0f4873c7129a2e32 Mon Sep 17 00:00:00 2001 From: Nexory Date: Tue, 4 Aug 2026 16:12:05 +0200 Subject: [PATCH 2/2] Match amount validation to parseUnits and note half-up rounding The Zod amount/value fields validated with a lenient parseFloat while the scaling consumer, viem's parseUnits, is strict. Inputs like "1e-3", "1_000", "+1" or " 1.5 " passed validation and then threw InvalidDecimalNumberError deep inside the handler. Centralize the field into positiveAmountSchema / nonNegativeAmountSchema in @internal/amount, validated against parseUnits' grammar, so a malformed amount now fails with a clear CLI message. Also correct the doc note: parseUnits rounds over-precision inputs half-up rather than truncating like the previous Math.floor. --- .../solana-to-base/bridge-call.handler.ts | 15 ++------ .../bridge-sol-with-bc.handler.ts | 12 ++----- .../solana-to-base/bridge-sol.handler.ts | 12 ++----- .../solana-to-base/bridge-spl.handler.ts | 12 ++----- .../bridge-wrapped-token.handler.ts | 12 ++----- scripts/src/commands/sol/spl/mint.handler.ts | 15 ++------ scripts/src/internal/amount.test.ts | 34 ++++++++++++++++++- scripts/src/internal/amount.ts | 26 +++++++++++++- 8 files changed, 70 insertions(+), 68 deletions(-) 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 9427a43..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,7 +14,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; -import { parseTokenAmount } from "@internal/amount"; +import { parseTokenAmount, nonNegativeAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -41,18 +41,7 @@ export const argsSchema = z.object({ z.literal("counter"), z.string().startsWith("0x", "Address must start with 0x").brand<"to">(), ]), - value: z - .string() - .refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n >= 0; - }, - { - message: "Value must be a non-negative number", - }, - ) - .default("0"), + value: nonNegativeAmountSchema, data: z .union([ z.literal("increment"), 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 6007dec..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,7 +19,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; -import { parseTokenAmount } from "@internal/amount"; +import { parseTokenAmount, positiveAmountSchema } from "@internal/amount"; import { FLYWHEEL_ABI } from "@internal/base/abi"; import { buildAndSendTransaction, @@ -47,15 +47,7 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z.string().refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n > 0; - }, - { - message: "Amount must be a positive number", - }, - ), + amount: positiveAmountSchema, builderCode: z .string() .regex(/^0x[a-fA-F0-9]{64}$/, { 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 d2afc01..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,7 +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 { parseTokenAmount, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -37,15 +37,7 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z.string().refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n > 0; - }, - { - message: "Amount must be a positive number", - }, - ), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), 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 a5487da..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,7 +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 { parseTokenAmount, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -62,15 +62,7 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z.string().refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n > 0; - }, - { - message: "Amount must be a positive number", - }, - ), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), 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 9604566..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,7 +23,7 @@ import { } from "@base/bridge/bridge"; import { logger } from "@internal/logger"; -import { parseTokenAmount } from "@internal/amount"; +import { parseTokenAmount, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -60,15 +60,7 @@ export const argsSchema = z.object({ message: "Invalid Base/Ethereum address format", }) .brand<"baseAddress">(), - amount: z.string().refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n > 0; - }, - { - message: "Amount must be a positive number", - }, - ), + amount: positiveAmountSchema, payerKp: z .union([z.literal("config"), z.string().brand<"payerKp">()]) .default("config"), diff --git a/scripts/src/commands/sol/spl/mint.handler.ts b/scripts/src/commands/sol/spl/mint.handler.ts index 4d87e9c..f59c41e 100644 --- a/scripts/src/commands/sol/spl/mint.handler.ts +++ b/scripts/src/commands/sol/spl/mint.handler.ts @@ -10,7 +10,7 @@ import { } from "@solana-program/token"; import { logger } from "@internal/logger"; -import { parseTokenAmount } from "@internal/amount"; +import { parseTokenAmount, positiveAmountSchema } from "@internal/amount"; import { buildAndSendTransaction, getSolanaCliConfigKeypairSigner, @@ -30,18 +30,7 @@ export const argsSchema = z.object({ to: z .union([z.literal("config"), z.string().brand<"to">()]) .default("config"), - amount: z - .string() - .refine( - (val) => { - const n = Number.parseFloat(val); - return !Number.isNaN(n) && n > 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"), diff --git a/scripts/src/internal/amount.test.ts b/scripts/src/internal/amount.test.ts index 09434e0..aaa0583 100644 --- a/scripts/src/internal/amount.test.ts +++ b/scripts/src/internal/amount.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import { parseTokenAmount } from "./amount"; +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)). @@ -25,3 +29,31 @@ 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 index 39daa0d..7ddb5ed 100644 --- a/scripts/src/internal/amount.ts +++ b/scripts/src/internal/amount.ts @@ -1,4 +1,26 @@ 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 @@ -12,7 +34,9 @@ import { parseUnits } from "viem"; * `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. + * 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);