Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from "@base/bridge/bridge";

import { logger } from "@internal/logger";
import { parseTokenAmount, nonNegativeAmountSchema } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand All @@ -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"),
Expand Down Expand Up @@ -114,7 +109,7 @@ export async function handleBridgeCall(args: Args): Promise<void> {
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
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}$/, {
Expand Down Expand Up @@ -97,7 +93,7 @@ export async function handleBridgeSolWithBc(args: Args): Promise<void> {
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}`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"),
Expand Down Expand Up @@ -74,7 +70,7 @@ export async function handleBridgeSol(args: Args): Promise<void> {
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}`);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -108,10 +104,8 @@ export async function handleBridgeSpl(args: Args): Promise<void> {
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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@base/bridge/bridge";

import { logger } from "@internal/logger";
import { parseTokenAmount, positiveAmountSchema } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -105,10 +101,8 @@ export async function handleBridgeWrappedToken(args: Args): Promise<void> {
});
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}`);
Expand Down
15 changes: 4 additions & 11 deletions scripts/src/commands/sol/spl/mint.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@solana-program/token";

import { logger } from "@internal/logger";
import { parseTokenAmount, positiveAmountSchema } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand All @@ -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"),
Expand Down Expand Up @@ -72,10 +67,8 @@ export async function handleMint(args: Args): Promise<void> {
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}`);
Expand Down
59 changes: 59 additions & 0 deletions scripts/src/internal/amount.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
43 changes: 43 additions & 0 deletions scripts/src/internal/amount.ts
Original file line number Diff line number Diff line change
@@ -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);
}