Skip to content
Open
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
16 changes: 16 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export {
ContractError,
CircuitOpenError,
ValidationError,
SplitRatioSumError,
PluginAlreadyRegisteredError,
InvalidBatchSizeError,
InvoiceNotReleasedError,
Expand Down
27 changes: 27 additions & 0 deletions src/payments/splitExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -99,6 +123,9 @@ export async function splitExecutor(
horizonUrl = "https://horizon.stellar.org",
} = options;

// Ratio validation (issue #778)
validateRecipientRatios(recipients);

const capacityChecks: Record<string, SubentryCapacityResult> = {};

if (!skipCapacityCheck) {
Expand Down
92 changes: 92 additions & 0 deletions test/splitExecutor.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading