From dbe728d1ab7fffca7daf72b22d24d83345374de4 Mon Sep 17 00:00:00 2001 From: real-venus Date: Thu, 25 Jun 2026 11:27:11 -0700 Subject: [PATCH] feat: multi-tariff decimal scaling engine with overflow protection (#3) Hardens src/utils/math.ts for 7-decimal Soroban fixed-point conversions: input range validation, explicit truncation instead of silent rounding, and i128 overflow guards. - validateScale(value): rejects >7 decimal places, exponential notation that isn't a safe integer, and integer parts beyond 10^12 (the round-to-zero bug for rates like 0.00000001 is now surfaced) - toContractUnits: explicitly truncates excess precision (ROUND_DOWN), supports a strict mode that rejects invalid scale, and throws OverflowError past i128; guards invalid numeric input - fromContractUnits: pure 7-decimal descaling - addSafe/subtractSafe/multiplySafe/divideSafe: throw OverflowError on i128 overflow and on divide-by-zero - adds I128_MAX/I128_MIN/MAX_INTEGER_PART constants and OverflowError/ ScaleValidationError; preserves the existing helper surface - tests cover zero, negative, max i128, very small values, exponential forms, truncation, overflow and round-trips (placed under tests/unit since vitest only collects tests/**, not src/__tests__) --- src/utils/math.ts | 226 +++++++++++++++++++++++++++++++++++++--- tests/unit/math.test.ts | 142 +++++++++++++++++++++++++ 2 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 tests/unit/math.test.ts diff --git a/src/utils/math.ts b/src/utils/math.ts index 2e80ad2..d4bc074 100644 --- a/src/utils/math.ts +++ b/src/utils/math.ts @@ -6,61 +6,253 @@ BigNumber.config({ EXPONENTIAL_AT: [-30, 30], }); +/** Soroban contract states are 7-decimal fixed-point integers (×10^7). */ export const SCALE = 7; export const SCALE_FACTOR = new BigNumber(10).pow(SCALE); -export function toContractUnits(value: string | number | BigNumber): string { - return new BigNumber(value).times(SCALE_FACTOR).toFixed(0, BigNumber.ROUND_HALF_UP); +/** Signed 128-bit integer bounds (Soroban i128). */ +export const I128_MAX = new BigNumber( + "170141183460469231731687303715884105727" +); +export const I128_MIN = new BigNumber( + "-170141183460469231731687303715884105728" +); + +/** Safe upper bound for the integer part of an authored value (10^12). */ +export const MAX_INTEGER_PART = new BigNumber(10).pow(12); + +/** Decimal value (the un-scaled, human-facing number) inputs accept. */ +export type DecimalInput = string | number | BigNumber; + +export class OverflowError extends Error { + constructor(message: string) { + super(message); + this.name = "OverflowError"; + } +} + +export class ScaleValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ScaleValidationError"; + } +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +export interface ScaleValidation { + valid: boolean; + reason?: string; +} + +/** Plain decimal (no exponent): optional sign, digits, optional fraction. */ +const DECIMAL_RE = /^[+-]?(\d+)(\.(\d+))?$/; +/** Exponential notation, e.g. 1e7, -2.5E-3. */ +const EXPONENTIAL_RE = /^[+-]?(\d+)(\.\d+)?[eE][+-]?\d+$/; + +/** + * Validate that a value is safe to convert to contract units: + * - at most {@link SCALE} (7) digits after the decimal point, + * - no exponential notation unless it represents a safe integer, + * - integer part not exceeding {@link MAX_INTEGER_PART} (10^12). + */ +export function validateScale(value: string): ScaleValidation { + if (typeof value !== "string" || value.trim() === "") { + return { valid: false, reason: "value must be a non-empty string" }; + } + const trimmed = value.trim(); + + const isExponential = EXPONENTIAL_RE.test(trimmed); + if (!isExponential && !DECIMAL_RE.test(trimmed)) { + return { valid: false, reason: "not a valid decimal number" }; + } + + const bn = new BigNumber(trimmed); + if (bn.isNaN() || !bn.isFinite()) { + return { valid: false, reason: "not a finite number" }; + } + + if (isExponential) { + // Exponential notation is only accepted when it denotes a safe integer, + // so authored fractions can't smuggle in hidden sub-1e-7 precision. + if (!bn.isInteger()) { + return { + valid: false, + reason: "exponential notation is only allowed for integers", + }; + } + if (!Number.isSafeInteger(bn.toNumber())) { + return { + valid: false, + reason: "exponential value is not a safe integer", + }; + } + } + + const decimals = bn.decimalPlaces() ?? 0; + if (decimals > SCALE) { + return { + valid: false, + reason: `more than ${SCALE} decimal places (${decimals})`, + }; + } + + const integerPart = bn.abs().integerValue(BigNumber.ROUND_DOWN); + if (integerPart.isGreaterThan(MAX_INTEGER_PART)) { + return { + valid: false, + reason: `integer part exceeds ${MAX_INTEGER_PART.toFixed(0)}`, + }; + } + + return { valid: true }; +} + +// --------------------------------------------------------------------------- +// Scaling (pure) +// --------------------------------------------------------------------------- + +export interface ScaleOptions { + /** Reject values that fail {@link validateScale} instead of truncating. */ + strict?: boolean; } -export function fromContractUnits(value: string | number | BigNumber): string { - return new BigNumber(value).div(SCALE_FACTOR).toFixed(SCALE, BigNumber.ROUND_HALF_UP); +function toBigNumber(value: DecimalInput): BigNumber { + let bn: BigNumber; + try { + bn = new BigNumber(value); + } catch { + throw new ScaleValidationError(`invalid numeric input: ${String(value)}`); + } + if (bn.isNaN() || !bn.isFinite()) { + throw new ScaleValidationError(`invalid numeric input: ${String(value)}`); + } + return bn; } -export function add(a: string | number | BigNumber, b: string | number | BigNumber): string { +/** + * Convert a human decimal to its 7-decimal contract-unit integer string. + * Extra precision is explicitly truncated (ROUND_DOWN); in `strict` mode a + * value with more than 7 decimals is rejected instead. Throws on i128 overflow. + */ +export function toContractUnits( + value: DecimalInput, + options: ScaleOptions = {} +): string { + if (options.strict && typeof value === "string") { + const result = validateScale(value); + if (!result.valid) { + throw new ScaleValidationError(result.reason ?? "invalid scale"); + } + } + + const bn = toBigNumber(value); + // Explicitly truncate beyond 7 decimals rather than silently rounding. + const truncated = bn.decimalPlaces(SCALE, BigNumber.ROUND_DOWN); + const scaled = truncated.times(SCALE_FACTOR).integerValue(BigNumber.ROUND_DOWN); + assertI128(scaled); + return scaled.toFixed(0); +} + +/** Convert a contract-unit integer back to a 7-decimal human string. */ +export function fromContractUnits(value: DecimalInput): string { + const bn = toBigNumber(value); + return bn.div(SCALE_FACTOR).toFixed(SCALE, BigNumber.ROUND_HALF_UP); +} + +/** Throw {@link OverflowError} if a contract-unit value is outside i128. */ +export function assertI128(scaled: BigNumber): void { + if (scaled.isGreaterThan(I128_MAX) || scaled.isLessThan(I128_MIN)) { + throw new OverflowError( + `value ${scaled.toFixed(0)} exceeds the i128 range` + ); + } +} + +// --------------------------------------------------------------------------- +// Safe arithmetic (throws on overflow) +// --------------------------------------------------------------------------- + +/** Assert a decimal result fits i128 once scaled to contract units. */ +function assertResultInRange(result: BigNumber): void { + assertI128(result.times(SCALE_FACTOR).integerValue(BigNumber.ROUND_DOWN)); +} + +export function addSafe(a: DecimalInput, b: DecimalInput): string { + const result = toBigNumber(a).plus(toBigNumber(b)); + assertResultInRange(result); + return result.toFixed(SCALE); +} + +export function subtractSafe(a: DecimalInput, b: DecimalInput): string { + const result = toBigNumber(a).minus(toBigNumber(b)); + assertResultInRange(result); + return result.toFixed(SCALE); +} + +export function multiplySafe(a: DecimalInput, b: DecimalInput): string { + const result = toBigNumber(a).times(toBigNumber(b)); + assertResultInRange(result); + return result.toFixed(SCALE); +} + +export function divideSafe(a: DecimalInput, b: DecimalInput): string { + const divisor = toBigNumber(b); + if (divisor.isZero()) { + throw new OverflowError("division by zero"); + } + const result = toBigNumber(a).div(divisor); + assertResultInRange(result); + return result.toFixed(SCALE); +} + +// --------------------------------------------------------------------------- +// General-purpose helpers (unchanged public surface) +// --------------------------------------------------------------------------- + +export function add(a: DecimalInput, b: DecimalInput): string { return new BigNumber(a).plus(b).toFixed(SCALE); } -export function subtract(a: string | number | BigNumber, b: string | number | BigNumber): string { +export function subtract(a: DecimalInput, b: DecimalInput): string { return new BigNumber(a).minus(b).toFixed(SCALE); } -export function multiply(a: string | number | BigNumber, b: string | number | BigNumber): string { +export function multiply(a: DecimalInput, b: DecimalInput): string { return new BigNumber(a).times(b).toFixed(SCALE); } -export function divide(a: string | number | BigNumber, b: string | number | BigNumber): string { +export function divide(a: DecimalInput, b: DecimalInput): string { return new BigNumber(a).div(b).toFixed(SCALE); } -export function compare(a: string | number | BigNumber, b: string | number | BigNumber): -1 | 0 | 1 { +export function compare(a: DecimalInput, b: DecimalInput): -1 | 0 | 1 { const diff = new BigNumber(a).minus(b); if (diff.isZero()) return 0; return diff.isPositive() ? 1 : -1; } -export function formatDisplay(value: string | number | BigNumber, decimals = 7): string { +export function formatDisplay(value: DecimalInput, decimals = 7): string { return new BigNumber(value).toFormat(decimals); } -export function formatCompact(value: string | number | BigNumber): string { +export function formatCompact(value: DecimalInput): string { const bn = new BigNumber(value); if (bn.isGreaterThanOrEqualTo(1_000_000)) return `${bn.div(1_000_000).toFixed(2)}M`; if (bn.isGreaterThanOrEqualTo(1_000)) return `${bn.div(1_000).toFixed(2)}K`; return bn.toFixed(2); } -export function isZero(value: string | number | BigNumber): boolean { +export function isZero(value: DecimalInput): boolean { return new BigNumber(value).isZero(); } -export function isGreaterThan( - a: string | number | BigNumber, - b: string | number | BigNumber -): boolean { +export function isGreaterThan(a: DecimalInput, b: DecimalInput): boolean { return new BigNumber(a).isGreaterThan(b); } -export function toNumber(value: string | number | BigNumber): number { +export function toNumber(value: DecimalInput): number { return new BigNumber(value).toNumber(); } diff --git a/tests/unit/math.test.ts b/tests/unit/math.test.ts new file mode 100644 index 0000000..c49620f --- /dev/null +++ b/tests/unit/math.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import BigNumber from "bignumber.js"; +import { + SCALE, + SCALE_FACTOR, + I128_MAX, + validateScale, + toContractUnits, + fromContractUnits, + addSafe, + subtractSafe, + multiplySafe, + divideSafe, + OverflowError, + ScaleValidationError, +} from "@/utils/math"; + +// Note: the issue specifies src/__tests__/math.test.ts, but the project's vitest +// config only picks up tests/**/*.test.{ts,tsx}; placed here so it runs in CI. + +describe("constants", () => { + it("SCALE is 7 and SCALE_FACTOR is 10^7", () => { + expect(SCALE).toBe(7); + expect(SCALE_FACTOR.toFixed(0)).toBe("10000000"); + }); +}); + +describe("validateScale", () => { + it("accepts up to 7 decimal places", () => { + expect(validateScale("1.2345678")).toEqual({ valid: true }); + expect(validateScale("0.0000001")).toEqual({ valid: true }); + expect(validateScale("-1.5")).toEqual({ valid: true }); + expect(validateScale("0")).toEqual({ valid: true }); + }); + + it("rejects more than 7 decimal places", () => { + const r = validateScale("1.23456789"); + expect(r.valid).toBe(false); + expect(r.reason).toMatch(/decimal places/); + }); + + it("rejects sub-1e-7 rates like 0.00000001 (the round-to-zero bug)", () => { + expect(validateScale("0.00000001").valid).toBe(false); + }); + + it("accepts exponential notation only for safe integers", () => { + expect(validateScale("1e7")).toEqual({ valid: true }); + expect(validateScale("1.5e3")).toEqual({ valid: true }); // 1500 + expect(validateScale("1.5e-3").valid).toBe(false); // 0.0015, not integer + expect(validateScale("1e20").valid).toBe(false); // not a safe integer + }); + + it("rejects an integer part beyond 10^12", () => { + expect(validateScale("1000000000000").valid).toBe(true); // exactly 10^12 + const r = validateScale("10000000000000"); // 10^13 + expect(r.valid).toBe(false); + expect(r.reason).toMatch(/integer part/); + }); + + it("rejects non-numbers and empty input", () => { + expect(validateScale("abc").valid).toBe(false); + expect(validateScale("").valid).toBe(false); + expect(validateScale(" ").valid).toBe(false); + }); +}); + +describe("toContractUnits", () => { + it("scales whole and fractional values", () => { + expect(toContractUnits("1")).toBe("10000000"); + expect(toContractUnits("0")).toBe("0"); + expect(toContractUnits("-1.5")).toBe("-15000000"); + expect(toContractUnits("0.0000001")).toBe("1"); + }); + + it("explicitly truncates beyond 7 decimals (no silent rounding up)", () => { + expect(toContractUnits("1.99999999")).toBe("19999999"); // truncated, not 20000000 + expect(toContractUnits("0.00000001")).toBe("0"); + }); + + it("strict mode rejects values with excess precision", () => { + expect(() => toContractUnits("1.23456789", { strict: true })).toThrow( + ScaleValidationError + ); + expect(() => toContractUnits("0.00000001", { strict: true })).toThrow(); + }); + + it("throws on i128 overflow", () => { + expect(() => toContractUnits("1e32")).toThrow(OverflowError); + }); + + it("throws on invalid numeric input", () => { + expect(() => toContractUnits("not-a-number")).toThrow(ScaleValidationError); + }); +}); + +describe("fromContractUnits", () => { + it("descales to a 7-decimal string", () => { + expect(fromContractUnits("10000000")).toBe("1.0000000"); + expect(fromContractUnits("0")).toBe("0.0000000"); + expect(fromContractUnits("-15000000")).toBe("-1.5000000"); + expect(fromContractUnits("1")).toBe("0.0000001"); + }); + + it("round-trips through toContractUnits", () => { + for (const v of ["3.1415926", "0.0000001", "123456.789", "-42.5"]) { + expect(fromContractUnits(toContractUnits(v))).toBe(new BigNumber(v).toFixed(7)); + } + }); + + it("handles the maximum i128 contract value", () => { + expect(() => fromContractUnits(I128_MAX.toFixed(0))).not.toThrow(); + }); +}); + +describe("safe arithmetic", () => { + it("adds, subtracts, multiplies and divides", () => { + expect(addSafe("1.5", "2.5")).toBe("4.0000000"); + expect(subtractSafe("5", "3")).toBe("2.0000000"); + expect(multiplySafe("2", "3")).toBe("6.0000000"); + expect(divideSafe("1", "4")).toBe("0.2500000"); + }); + + it("handles zero and negative operands", () => { + expect(addSafe("0", "0")).toBe("0.0000000"); + expect(subtractSafe("0", "5")).toBe("-5.0000000"); + expect(multiplySafe("-2", "3")).toBe("-6.0000000"); + }); + + it("throws on division by zero", () => { + expect(() => divideSafe("1", "0")).toThrow(OverflowError); + }); + + it("throws on overflow past i128", () => { + expect(() => addSafe("1e31", "1e31")).toThrow(OverflowError); + expect(() => multiplySafe("1e20", "1e20")).toThrow(OverflowError); + }); + + it("allows values right up to the i128 boundary", () => { + const maxDecimal = I128_MAX.div(SCALE_FACTOR).integerValue().toFixed(0); + expect(() => addSafe(maxDecimal, "0")).not.toThrow(); + }); +});