Skip to content
Merged
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
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 71 additions & 1 deletion src/validators/splitRatioValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
}
66 changes: 66 additions & 0 deletions test/splitRatioValidator.total.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});