Skip to content

fix(payments): reject splits whose ratios do not sum to 1.0 - #831

Open
woahwhattheheck wants to merge 3 commits into
Stellar-split:mainfrom
woahwhattheheck:feat/778-split-ratio-sum
Open

fix(payments): reject splits whose ratios do not sum to 1.0#831
woahwhattheheck wants to merge 3 commits into
Stellar-split:mainfrom
woahwhattheheck:feat/778-split-ratio-sum

Conversation

@woahwhattheheck

Copy link
Copy Markdown

Closes #778.

splitExecutor ran its capacity pre-flight and returned success without ever
checking that the recipients' shares add up. A rounding error or a typo in the
percentages silently produced a split that over- or underpaid the total.

Changes

src/payments/splitExecutor.ts:

  • SPLIT_RATIO_TOLERANCE — exported, 1e-9.
  • SplitRatioSumError — thrown when the sum is out of tolerance, carrying
    actualSum and tolerance.
  • assertSplitRatiosSumToOne(recipients) — exported so callers can validate a
    split without executing it.
  • SplitRecipient.ratio?: number — a recipient's share as a fraction in
    [0, 1].
  • splitExecutor calls the check first, before any network work, so a
    mis-allocated split never reaches the capacity checks.

Backward compatibility

SplitRecipient had no ratio field, so the check has to decide what an
existing amount-only caller means. A split that declares no ratios at all
is treated as amount-only and passes untouched — every current caller is
unaffected. Once any recipient declares a ratio, the whole split is validated,
and a recipient that omits one contributes 0, which surfaces as a failed sum
rather than being silently skipped.

Two details

  • The tolerance is not cosmetic. The same three ratios sum differently
    depending on order: 0.1 + 0.2 + 0.7 === 1, but 0.7 + 0.2 + 0.1 === 0.9999999999999999. An exact comparison would reject the second ordering,
    which is why 1e-9 exists and why the test uses that ordering.
  • A NaN ratio fails. The comparison is written as a negated <= so a
    NaN sum throws instead of slipping through, since every comparison against
    NaN is false.

Validation

  • npx vitest run test/splitExecutor.ratio.test.ts — 13/13 passing. Covers
    amount-only splits, empty lists, exact sums, the floating-point ordering
    above, under- and over-allocation, the reported actualSum, an omitted
    ratio, NaN, and rejection through splitExecutor itself.
  • Tests are network-free: the validator is pure, and splitExecutor is
    exercised with skipCapacityCheck: true, so no Horizon call is made.
  • npx tsc --noEmit reports 210 errors on this branch and 210 on unmodified
    main
    — identical, with none in src/payments/splitExecutor.ts. That
    baseline is pre-existing and unrelated to this change.

Recipient percentages were never validated, so a rounding error or a typo
produced a split that silently over- or underpaid the total. splitExecutor now
checks the sum before any network work, within SPLIT_RATIO_TOLERANCE, and
throws SplitRatioSumError carrying the actual sum.

Amount-only splits declare no ratios and are unaffected.
0.1+0.2+0.7 sums to exactly 1; 0.7+0.2+0.1 does not. The tolerance case
needs the ordering that genuinely falls short.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Validate that split amounts sum to exactly 100% before execution in splitExecutor

1 participant