From b207c869ee35ddf5c9b4f85ae3707e899dc16712 Mon Sep 17 00:00:00 2001 From: Kyle Cox Date: Fri, 9 Oct 2026 06:40:56 +0000 Subject: [PATCH] fix(common): don't mutate the caller's options in decimalize()/undecimalize() --- .changeset/tidy-bigints-share.md | 5 ++++ packages/common/src/utils/bigInt.spec.ts | 27 ++++++++++++++++++++ packages/common/src/utils/bigInt.ts | 32 ++++++++++++------------ 3 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 .changeset/tidy-bigints-share.md diff --git a/.changeset/tidy-bigints-share.md b/.changeset/tidy-bigints-share.md new file mode 100644 index 00000000..aa5484de --- /dev/null +++ b/.changeset/tidy-bigints-share.md @@ -0,0 +1,5 @@ +--- +"@fleet-sdk/common": patch +--- + +Fix `decimalize()` and `undecimalize()` mutating the caller's options object: the resolved `decimals` and `decimalMark` defaults were assigned back into the passed object, so a caller's options came back with fields it never set (and a frozen options object threw a `TypeError`). The defaults are now resolved into local variables, leaving the caller's object untouched; outputs are unchanged. diff --git a/packages/common/src/utils/bigInt.spec.ts b/packages/common/src/utils/bigInt.spec.ts index 8f9d4fa0..b911dd6d 100644 --- a/packages/common/src/utils/bigInt.spec.ts +++ b/packages/common/src/utils/bigInt.spec.ts @@ -78,6 +78,17 @@ describe("decimalize()", () => { const options = { decimals: 9 }; expect(decimalize(undecimalize("129.8379183", options), options)).toBe("129.8379183"); }); + + it("Should not mutate the caller's options object", () => { + const options = { decimals: 2 }; + expect(decimalize(1763874n, options)).toBe("17638.74"); + expect(options).toEqual({ decimals: 2 }); + }); + + it("Should accept a frozen options object", () => { + const options = Object.freeze({ decimals: 2 }); + expect(decimalize(1763874n, options)).toBe("17638.74"); + }); }); describe("undecimalize()", () => { @@ -115,6 +126,22 @@ describe("undecimalize()", () => { undecimalize("12.23.1"); }).toThrow(); }); + + it("Should not mutate the caller's options object", () => { + const options = { decimals: 2 }; + expect(undecimalize("1.23", options)).toBe(123n); + expect(options).toEqual({ decimals: 2 }); + }); + + it("Should accept a frozen options object", () => { + const options = Object.freeze({ decimals: 2 }); + expect(undecimalize("1.23", options)).toBe(123n); + }); + + it("Should accept frozen options relying on defaults", () => { + const options = Object.freeze({}); + expect(undecimalize("1.23", options)).toBe(123n); + }); }); describe("ensureBigInt()", () => { diff --git a/packages/common/src/utils/bigInt.ts b/packages/common/src/utils/bigInt.ts index ee352ef0..fd3fb277 100644 --- a/packages/common/src/utils/bigInt.ts +++ b/packages/common/src/utils/bigInt.ts @@ -47,15 +47,11 @@ type ParsingOptions = { export function undecimalize(input: string, options?: ParsingOptions | number): bigint { if (!input) return _0n; - options = typeof options === "number" ? { decimals: options } : options; - if (isUndefined(options)) { - options = {}; - } - - options.decimals = options.decimals || 0; - options.decimalMark = options.decimalMark || "."; + const parsedOptions = typeof options === "number" ? { decimals: options } : options; + const decimals = parsedOptions?.decimals || 0; + const decimalMark = parsedOptions?.decimalMark || "."; - const fragments = input.split(options.decimalMark); + const fragments = input.split(decimalMark); if (fragments.length > 2) { throw new Error("Invalid numeric string."); } @@ -65,9 +61,9 @@ export function undecimalize(input: string, options?: ParsingOptions | number): const negative = integer.startsWith("-") ? "-" : ""; if (!decimal) { - decimal = "0".repeat(options.decimals); - } else if (decimal.length < options.decimals) { - decimal = decimal.padEnd(options.decimals, "0"); + decimal = "0".repeat(decimals); + } else if (decimal.length < decimals) { + decimal = decimal.padEnd(decimals, "0"); } return BigInt(negative + _stripNonDigits(integer + decimal)); @@ -114,15 +110,19 @@ export function decimalize(value: Amount, options?: FormattingOptions | number): return value.toString(); } - options = typeof options === "number" ? { decimals: options } : options; - options.decimals = options.decimals || 0; - options.decimalMark = options.decimalMark || "."; + const parsedOptions = typeof options === "number" ? { decimals: options } : options; + const decimals = parsedOptions.decimals || 0; + const decimalMark = parsedOptions.decimalMark || "."; - const pow = _10n ** BigInt(options.decimals); + const pow = _10n ** BigInt(decimals); const integer = value / pow; const decimal = value - integer * pow; - return buildFormattedDecimal(integer.toString(10), decimal.toString(10), options); + return buildFormattedDecimal(integer.toString(10), decimal.toString(10), { + ...parsedOptions, + decimals, + decimalMark + }); } /**