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 } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -114,7 +120,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 } from "@internal/amount";
import { FLYWHEEL_ABI } from "@internal/base/abi";
import {
buildAndSendTransaction,
Expand Down Expand Up @@ -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}$/, {
Expand Down Expand Up @@ -97,7 +101,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 } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand All @@ -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"),
Expand Down Expand Up @@ -74,7 +78,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 } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -108,10 +112,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 } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -105,10 +109,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
22 changes: 13 additions & 9 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 } from "@internal/amount";
import {
buildAndSendTransaction,
getSolanaCliConfigKeypairSigner,
Expand All @@ -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"),
Expand Down Expand Up @@ -72,10 +78,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
27 changes: 27 additions & 0 deletions scripts/src/internal/amount.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
19 changes: 19 additions & 0 deletions scripts/src/internal/amount.ts
Original file line number Diff line number Diff line change
@@ -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);
}