From 40136fa8c84e626bdbc7c3beb82081c335e338c7 Mon Sep 17 00:00:00 2001 From: ehonrie Date: Wed, 26 Aug 2026 15:18:16 -0700 Subject: [PATCH] feat: update on issue 609 --- src/index.ts | 8 ++- src/validators/splitRatioValidator.ts | 72 +++++++++++++++++++++++++- test/splitRatioValidator.total.test.ts | 66 +++++++++++++++++++++++ 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 test/splitRatioValidator.total.test.ts diff --git a/src/index.ts b/src/index.ts index ef5f0c0..65430d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -995,7 +995,13 @@ export type { // Split ratio validator // --------------------------------------------------------------------------- -export { validateSplitRatios, validateSplitRatiosOrThrow, ratiosToRecipients } from "./validators/splitRatioValidator.js"; +export { + validateSplitRatios, + validateSplitRatiosOrThrow, + ratiosToRecipients, + validateSplitTotal, + normalizeSplits, +} from "./validators/splitRatioValidator.js"; export type { RecipientShare, SplitConfig, diff --git a/src/validators/splitRatioValidator.ts b/src/validators/splitRatioValidator.ts index 6bfd0e9..b2bcbf0 100644 --- a/src/validators/splitRatioValidator.ts +++ b/src/validators/splitRatioValidator.ts @@ -11,7 +11,7 @@ * actionable error objects. */ -import { ValidationError } from "../errors.js"; +import { ValidationError, SdkError, SdkErrorCode } from "../errors.js"; import type { Recipient } from "../types.js"; // --------------------------------------------------------------------------- @@ -178,3 +178,73 @@ export function ratiosToRecipients( amount: amounts[i]!, })); } + +// --------------------------------------------------------------------------- +// Precise bigint-based total validation +// --------------------------------------------------------------------------- + +/** Default total, expressed in basis points (10000 = 100.00%). */ +const DEFAULT_TOTAL_BASIS_POINTS = 10_000n; + +/** + * Validate that an array of bigint split values sums to exactly + * `totalBasisPoints` (defaults to 10000n, i.e. 100.00%). + * + * Uses only bigint arithmetic so floating-point rounding (e.g. + * 0.1 + 0.2 !== 0.3) can never mask an invalid split. + * + * @param splits - Recipient split values in basis points. + * @param totalBasisPoints - Expected sum. Defaults to 10000n. + * @throws SdkError with code {@link SdkErrorCode.INVALID_RECIPIENT} when the + * splits array is empty or the sum does not equal `totalBasisPoints`. + */ +export function validateSplitTotal( + splits: bigint[], + totalBasisPoints: bigint = DEFAULT_TOTAL_BASIS_POINTS, +): void { + if (splits.length === 0) { + throw new SdkError( + "splits must sum to 10000 basis points", + SdkErrorCode.INVALID_RECIPIENT, + { splits, totalBasisPoints }, + ); + } + + let sum = 0n; + for (const split of splits) { + sum += split; + } + + if (sum !== totalBasisPoints) { + throw new SdkError( + "splits must sum to 10000 basis points", + SdkErrorCode.INVALID_RECIPIENT, + { splits, sum, totalBasisPoints }, + ); + } +} + +/** + * Normalize an array of bigint amounts so they sum to exactly `total`, + * distributing any rounding remainder to the last recipient. + * + * @param amounts - Recipient amounts (e.g. produced by a proportional split). + * @param total - The exact total the amounts must sum to. + * @returns A new array of the same length whose values sum to `total`. + */ +export function normalizeSplits(amounts: bigint[], total: bigint): bigint[] { + if (amounts.length === 0) { + return []; + } + + const normalized = amounts.slice(); + let sum = 0n; + for (const amount of normalized) { + sum += amount; + } + + const remainder = total - sum; + normalized[normalized.length - 1] = normalized[normalized.length - 1]! + remainder; + + return normalized; +} diff --git a/test/splitRatioValidator.total.test.ts b/test/splitRatioValidator.total.test.ts new file mode 100644 index 0000000..dbe6d3b --- /dev/null +++ b/test/splitRatioValidator.total.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + validateSplitTotal, + normalizeSplits, +} from "../src/validators/splitRatioValidator.js"; +import { SdkError, SdkErrorCode } from "../src/errors.js"; + +describe("validateSplitTotal", () => { + it("passes when splits sum exactly to the default total (10000n)", () => { + expect(() => validateSplitTotal([3000n, 3000n, 4000n])).not.toThrow(); + }); + + it("passes when splits sum exactly to a custom total", () => { + expect(() => validateSplitTotal([500n, 500n], 1000n)).not.toThrow(); + }); + + it("throws SdkError with code INVALID_RECIPIENT when off by one", () => { + expect(() => validateSplitTotal([3000n, 3000n, 3999n])).toThrow(SdkError); + try { + validateSplitTotal([3000n, 3000n, 3999n]); + throw new Error("expected validateSplitTotal to throw"); + } catch (err) { + expect(err).toBeInstanceOf(SdkError); + expect((err as SdkError).code).toBe(SdkErrorCode.INVALID_RECIPIENT); + expect((err as SdkError).message).toBe( + "splits must sum to 10000 basis points", + ); + } + }); + + it("throws SdkError with code INVALID_RECIPIENT for an empty array", () => { + expect(() => validateSplitTotal([])).toThrow(SdkError); + try { + validateSplitTotal([]); + throw new Error("expected validateSplitTotal to throw"); + } catch (err) { + expect(err).toBeInstanceOf(SdkError); + expect((err as SdkError).code).toBe(SdkErrorCode.INVALID_RECIPIENT); + } + }); +}); + +describe("normalizeSplits", () => { + it("round-trips: normalized amounts sum exactly to total", () => { + const amounts = [3333n, 3333n, 3333n]; + const total = 10000n; + const normalized = normalizeSplits(amounts, total); + + const sum = normalized.reduce((acc, v) => acc + v, 0n); + expect(sum).toBe(total); + expect(() => validateSplitTotal(normalized, total)).not.toThrow(); + }); + + it("distributes the rounding remainder to the last recipient", () => { + const amounts = [3333n, 3333n, 3333n]; + const normalized = normalizeSplits(amounts, 10000n); + + expect(normalized[0]).toBe(3333n); + expect(normalized[1]).toBe(3333n); + expect(normalized[2]).toBe(3334n); + }); + + it("returns an empty array unchanged", () => { + expect(normalizeSplits([], 10000n)).toEqual([]); + }); +});