diff --git a/src/errors.ts b/src/errors.ts index 02e444b..8b6ced2 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -299,6 +299,22 @@ export class ValidationError extends StellarSplitError { } } +/** Thrown when split recipient ratios do not sum to exactly 1.0. */ +export class SplitRatioSumError extends StellarSplitError { + readonly actualSum: number; + + constructor(actualSum: number, tolerance: number) { + super( + `Split ratios sum to ${actualSum} but must equal 1.0 (tolerance: ±${tolerance})`, + "SPLIT_RATIO_SUM_ERROR", + { actualSum, tolerance }, + ); + this.name = "SplitRatioSumError"; + this.actualSum = actualSum; + Object.setPrototypeOf(this, new.target.prototype); + } +} + /** Thrown when a plugin with the same name is already registered. */ export class PluginAlreadyRegisteredError extends StellarSplitError { readonly pluginName: string; diff --git a/src/index.ts b/src/index.ts index ebf7e30..f5c0a11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,6 +57,7 @@ export { ContractError, CircuitOpenError, ValidationError, + SplitRatioSumError, PluginAlreadyRegisteredError, InvalidBatchSizeError, InvoiceNotReleasedError, diff --git a/src/payments/splitExecutor.ts b/src/payments/splitExecutor.ts index d26b839..d116685 100644 --- a/src/payments/splitExecutor.ts +++ b/src/payments/splitExecutor.ts @@ -12,17 +12,23 @@ import { checkSubentryCapacity, SubentryCapacityGuardError } from "../account/subentryGuard.js"; import type { SubentryCapacityResult } from "../types.js"; +import { SplitRatioSumError } from "../errors.js"; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- +/** Tolerance for floating-point ratio-sum comparison. Exported for callers. */ +export const SPLIT_RATIO_TOLERANCE = 1e-9; + /** A single recipient leg in a split payment. */ export interface SplitRecipient { /** Stellar G… address of the recipient. */ address: string; /** Amount to send in stroops. */ amount: bigint; + /** Optional share ratio (0.0–1.0). When provided, splitExecutor validates that all ratios sum to 1.0. */ + ratio?: number; /** * Number of new subentry slots this recipient will consume as a result of * this operation (e.g., 1 for a new trustline, 1 for a new data entry). @@ -63,6 +69,23 @@ export interface SplitExecutionResult { // splitExecutor // --------------------------------------------------------------------------- +/** + * Validates that any provided recipient ratios sum to 1.0 within tolerance. + * @throws {SplitRatioSumError} When ratios are provided and do not sum to 1.0. + */ +function validateRecipientRatios(recipients: SplitRecipient[]): void { + const ratios = recipients + .map((r) => r.ratio) + .filter((r): r is number => r !== undefined); + + if (ratios.length === 0) return; + + const sum = ratios.reduce((acc, r) => acc + r, 0); + if (Math.abs(sum - 1.0) > SPLIT_RATIO_TOLERANCE) { + throw new SplitRatioSumError(sum, SPLIT_RATIO_TOLERANCE); + } +} + /** * Executes a multi-recipient split payment after running subentry capacity * pre-flight checks for each recipient. @@ -74,6 +97,7 @@ export interface SplitExecutionResult { * * @throws {SubentryCapacityGuardError} When any recipient's account cannot * accommodate the required subentry slots and `skipCapacityCheck` is not set. + * @throws {SplitRatioSumError} When recipient ratios are provided and do not sum to 1.0. * * @example * ```ts @@ -99,6 +123,9 @@ export async function splitExecutor( horizonUrl = "https://horizon.stellar.org", } = options; + // Ratio validation (issue #778) + validateRecipientRatios(recipients); + const capacityChecks: Record = {}; if (!skipCapacityCheck) { diff --git a/test/splitExecutor.test.ts b/test/splitExecutor.test.ts new file mode 100644 index 0000000..a5e6f03 --- /dev/null +++ b/test/splitExecutor.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { + splitExecutor, + SPLIT_RATIO_TOLERANCE, + type SplitRecipient, +} from "../src/payments/splitExecutor.js"; +import { SplitRatioSumError } from "../src/errors.js"; + +// Mock the subentry guard so tests don't hit Horizon +vi.mock("../src/account/subentryGuard.js", () => ({ + checkSubentryCapacity: vi.fn().mockResolvedValue({ sufficient: true, availableSlots: 10 }), + SubentryCapacityGuardError: class extends Error {}, +})); + +describe("splitExecutor", () => { + it("proceeds when no ratios are provided", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 5_000_000n }, + { address: "GDEF...", amount: 5_000_000n }, + ]; + const result = await splitExecutor(recipients, { skipCapacityCheck: true }); + expect(result.success).toBe(true); + }); + + it("proceeds when ratios sum to 1.0 within tolerance", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 4_000_000n, ratio: 0.4 }, + { address: "GDEF...", amount: 3_000_000n, ratio: 0.3 }, + { address: "GHIJ...", amount: 3_000_000n, ratio: 0.3 }, + ]; + const result = await splitExecutor(recipients, { skipCapacityCheck: true }); + expect(result.success).toBe(true); + }); + + it("throws SplitRatioSumError when ratios sum to less than 1.0", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 3_000_000n, ratio: 0.3 }, + { address: "GDEF...", amount: 3_000_000n, ratio: 0.3 }, + ]; + await expect(splitExecutor(recipients, { skipCapacityCheck: true })).rejects.toThrow( + SplitRatioSumError, + ); + }); + + it("throws SplitRatioSumError when ratios sum to more than 1.0", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 6_000_000n, ratio: 0.6 }, + { address: "GDEF...", amount: 5_000_000n, ratio: 0.5 }, + ]; + await expect(splitExecutor(recipients, { skipCapacityCheck: true })).rejects.toThrow( + SplitRatioSumError, + ); + }); + + it("includes actualSum in the thrown SplitRatioSumError", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 3_000_000n, ratio: 0.3 }, + { address: "GDEF...", amount: 3_000_000n, ratio: 0.3 }, + ]; + try { + await splitExecutor(recipients, { skipCapacityCheck: true }); + expect.fail("Should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(SplitRatioSumError); + expect((err as SplitRatioSumError).actualSum).toBeCloseTo(0.6, 10); + } + }); + + it("tolerates floating-point rounding within SPLIT_RATIO_TOLERANCE", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 3_333_333n, ratio: 0.3333333333 }, + { address: "GDEF...", amount: 3_333_333n, ratio: 0.3333333333 }, + { address: "GHIJ...", amount: 3_333_334n, ratio: 0.3333333334 }, + ]; + const result = await splitExecutor(recipients, { skipCapacityCheck: true }); + expect(result.success).toBe(true); + }); + + it("rejects when rounding exceeds SPLIT_RATIO_TOLERANCE", async () => { + const recipients: SplitRecipient[] = [ + { address: "GABC...", amount: 5_000_000n, ratio: 0.5 }, + { address: "GDEF...", amount: 5_000_000n, ratio: 0.500000002 }, + ]; + await expect(splitExecutor(recipients, { skipCapacityCheck: true })).rejects.toThrow( + SplitRatioSumError, + ); + }); + + it("exports SPLIT_RATIO_TOLERANCE as 1e-9", () => { + expect(SPLIT_RATIO_TOLERANCE).toBe(1e-9); + }); +});