Skip to content

Commit ccba5ac

Browse files
authored
Merge pull request #79 from eulami/fix/43-split-release-basis-point-reconciliation
fix(escrow): derive split-release payments from on-chain basis points
2 parents ef602d0 + 3e2e18c commit ccba5ac

5 files changed

Lines changed: 259 additions & 10 deletions

File tree

src/common/validators/money.validator.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ export function amountToStroops(amount: string): bigint {
3333
return BigInt(whole) * STROOP_SCALE + BigInt(fraction.padEnd(7, '0'));
3434
}
3535

36+
/** Inverse of {@link amountToStroops}: formats a stroop total back to a 7-decimal-place string. */
37+
export function stroopsToAmount(stroops: bigint): string {
38+
if (stroops < 0n) {
39+
throw new Error('Stroops amount must be non-negative');
40+
}
41+
const whole = stroops / STROOP_SCALE;
42+
const fraction = stroops % STROOP_SCALE;
43+
return `${whole.toString()}.${fraction.toString().padStart(7, '0')}`;
44+
}
45+
3646
export function isSupportedEscrowAsset(value: unknown): value is AssetType {
3747
return SUPPORTED_ESCROW_ASSETS.includes(value as AssetType);
3848
}

src/escrow/escrow.service.spec.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,5 +258,48 @@ describe('EscrowService', () => {
258258
expect(payments[1].amount).toBe('40.0000000');
259259
expect(payments[2].amount).toBe('20.0000000');
260260
});
261+
262+
it('records split amounts that sum to exactly escrow.amount in stroops (#43)', async () => {
263+
escrowRepo.findOne.mockResolvedValue({
264+
id: 'escrow-uneven',
265+
status: EscrowStatus.LOCKED,
266+
amount: '100.0000000',
267+
asset: AssetType.USDC,
268+
bountyId: 'bounty-uneven',
269+
});
270+
271+
const payments = await service.splitRelease('escrow-uneven', [
272+
{ recipientAddress: 'GA', percentage: 33.33 },
273+
{ recipientAddress: 'GB', percentage: 33.33 },
274+
{ recipientAddress: 'GC', percentage: 33.34 },
275+
]);
276+
277+
const totalStroops = payments.reduce(
278+
(sum, p) => sum + BigInt(Math.round(Number(p.amount) * 1e7)),
279+
0n,
280+
);
281+
expect(totalStroops).toBe(1_000_000_000n);
282+
});
283+
284+
it('sends basis points on-chain that sum to exactly 10,000', async () => {
285+
escrowRepo.findOne.mockResolvedValue({
286+
id: 'escrow-bps',
287+
status: EscrowStatus.LOCKED,
288+
amount: '100.0000000',
289+
asset: AssetType.USDC,
290+
bountyId: 'bounty-bps',
291+
});
292+
293+
await service.splitRelease('escrow-bps', [
294+
{ recipientAddress: 'GA', percentage: 33.333 },
295+
{ recipientAddress: 'GB', percentage: 33.333 },
296+
{ recipientAddress: 'GC', percentage: 33.334 },
297+
]);
298+
299+
const invokeCall = soroban.invoke.mock.calls[0] as unknown[];
300+
const splitArgs = invokeCall[1] as unknown[];
301+
const bps = splitArgs[2] as number[];
302+
expect(bps.reduce((a, b) => a + b, 0)).toBe(10_000);
303+
});
261304
});
262305
});

src/escrow/escrow.service.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import {
1212
amountToStroops,
1313
isSupportedEscrowAsset,
1414
isValidMoneyAmount,
15+
stroopsToAmount,
1516
} from '../common/validators/money.validator';
1617
import { SorobanClientService } from './soroban-client.service';
18+
import { apportionBasisPoints, splitStroops } from './split-math.util';
1719

1820
export interface FundEscrowInput {
1921
amount: string;
@@ -129,6 +131,13 @@ export class EscrowService {
129131
/**
130132
* Splits the escrowed amount across multiple recipients by percentage
131133
* (team bounties). Percentages must sum to exactly 100.
134+
*
135+
* The recorded `Payment.amount` values are derived from the same
136+
* basis-point integers sent on-chain — not recomputed independently from the
137+
* raw percentages — so the local ledger can never drift from what was
138+
* instructed to the contract. Shares are allocated in whole stroops via a
139+
* largest-remainder method, guaranteeing `sum(payments.amount) ===
140+
* escrow.amount` exactly (#43).
132141
*/
133142
async splitRelease(
134143
escrowId: string,
@@ -138,30 +147,36 @@ export class EscrowService {
138147
this.assertLocked(escrow);
139148
this.assertValidSplits(recipients);
140149

150+
const totalStroops = amountToStroops(escrow.amount);
151+
// Single source of truth for the split: integer basis points summing to
152+
// exactly 10,000 (100.00%), used both on-chain and to derive the ledger.
153+
const bps = apportionBasisPoints(recipients.map((r) => r.percentage));
154+
141155
const result = await this.soroban.invoke('split_release', [
142156
escrow.bountyId ?? escrow.milestoneId ?? escrow.id,
143157
recipients.map((r) => r.recipientAddress),
144-
recipients.map((r) => Math.round(r.percentage * 100)), // basis points-ish, 2dp -> integer
158+
bps,
145159
]);
146160

161+
const shares = splitStroops(totalStroops, bps);
162+
this.reconcileSplitResult(escrow.id, totalStroops, result.returnValue);
163+
147164
escrow.status = EscrowStatus.RELEASED;
148165
escrow.releaseTxHash = result.txHash;
149166
escrow.releasedAt = new Date();
167+
escrow.metadata = { ...(escrow.metadata ?? {}), splitRelease: result };
150168
await this.escrowRepo.save(escrow);
151169

152-
const totalAmount = Number(escrow.amount);
153170
const payments: Payment[] = [];
154-
for (const recipient of recipients) {
155-
const share = this.roundAmount(
156-
(totalAmount * recipient.percentage) / 100,
157-
);
171+
for (let i = 0; i < recipients.length; i++) {
172+
const recipient = recipients[i];
158173
const payment = this.paymentRepo.create({
159174
escrowId: escrow.id,
160175
recipientId: recipient.recipientId ?? null,
161176
recipientAddress: recipient.recipientAddress,
162-
amount: share.toFixed(7),
177+
amount: stroopsToAmount(shares[i]),
163178
asset: escrow.asset,
164-
splitPercentage: recipient.percentage.toFixed(2),
179+
splitPercentage: (bps[i] / 100).toFixed(2),
165180
status: PaymentStatus.CONFIRMED,
166181
txHash: result.txHash,
167182
});
@@ -283,8 +298,41 @@ export class EscrowService {
283298
}
284299
}
285300

286-
private roundAmount(value: number): number {
287-
return Math.round(value * 1e7) / 1e7;
301+
/**
302+
* The illustrative split_release contract returns a single i128 (the total
303+
* released, in stroops) rather than a per-recipient breakdown, so the
304+
* recorded Payment rows cannot yet be derived from `result.returnValue`
305+
* (see the interface TODO in soroban-client.service.ts). Until the deployed
306+
* contract returns per-recipient amounts, reconcile the scalar total against
307+
* the locally computed total and surface any divergence as a warning for the
308+
* reconciliation job, rather than silently discarding it (#43).
309+
*/
310+
private reconcileSplitResult(
311+
escrowId: string,
312+
totalStroops: bigint,
313+
returnValue: unknown,
314+
): void {
315+
const returned = this.toStroopsFromReturnValue(returnValue);
316+
if (returned === null) return;
317+
if (returned !== totalStroops) {
318+
this.logger.warn(
319+
`split_release returnValue (${returned} stroops) diverges from the ` +
320+
`recorded total (${totalStroops} stroops) for escrow ${escrowId}`,
321+
);
322+
}
323+
}
324+
325+
/** Best-effort conversion of a contract return value to a stroop total. */
326+
private toStroopsFromReturnValue(value: unknown): bigint | null {
327+
if (value == null) return null;
328+
if (typeof value === 'bigint') return value;
329+
if (typeof value === 'number' && Number.isFinite(value)) {
330+
return BigInt(Math.trunc(value));
331+
}
332+
if (typeof value === 'string' && /^-?\d+$/.test(value.trim())) {
333+
return BigInt(value.trim());
334+
}
335+
return null;
288336
}
289337

290338
private assertValidFundInput(input: FundEscrowInput): void {

src/escrow/split-math.util.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import {
3+
apportionBasisPoints,
4+
splitStroops,
5+
TOTAL_BASIS_POINTS,
6+
} from './split-math.util';
7+
8+
describe('apportionBasisPoints', () => {
9+
it('sums to exactly 10,000 basis points', () => {
10+
const bps = apportionBasisPoints([33.33, 33.33, 33.34]);
11+
expect(bps.reduce((a, b) => a + b, 0)).toBe(TOTAL_BASIS_POINTS);
12+
});
13+
14+
it('normalizes naive rounding for repeating-decimal percentages', () => {
15+
// Math.round(33.333*100) = 3333 for all three, summing to 9999 — the
16+
// apportionment must hand the missing basis point to the largest remainder.
17+
const bps = apportionBasisPoints([33.333, 33.333, 33.334]);
18+
expect(bps).toEqual([3333, 3333, 3334]);
19+
expect(bps.reduce((a, b) => a + b, 0)).toBe(TOTAL_BASIS_POINTS);
20+
});
21+
22+
it('keeps every basis point positive for tiny shares', () => {
23+
const bps = apportionBasisPoints([0.01, 99.99]);
24+
expect(bps[0]).toBeGreaterThan(0);
25+
expect(bps.reduce((a, b) => a + b, 0)).toBe(TOTAL_BASIS_POINTS);
26+
});
27+
28+
it('rejects an empty percentage list', () => {
29+
expect(() => apportionBasisPoints([])).toThrow(BadRequestException);
30+
});
31+
});
32+
33+
describe('splitStroops', () => {
34+
it('reproduces the issue repro exactly (100.0000000 -> 1,000,000,000 stroops)', () => {
35+
const shares = splitStroops(1_000_000_000n, [3333, 3333, 3334]);
36+
expect(shares).toEqual([333_300_000n, 333_300_000n, 333_400_000n]);
37+
expect(shares.reduce((a, b) => a + b, 0n)).toBe(1_000_000_000n);
38+
});
39+
40+
it('allocates the rounding remainder so the sum is exact', () => {
41+
const shares = splitStroops(1_000_000_001n, [5000, 5000]);
42+
expect(shares.reduce((a, b) => a + b, 0n)).toBe(1_000_000_001n);
43+
});
44+
45+
it('splits a non-divisible amount across uneven thirds exactly', () => {
46+
const total = 10_000_000_007n; // 1000.0000007
47+
const shares = splitStroops(total, [3333, 3333, 3334]);
48+
expect(shares.reduce((a, b) => a + b, 0n)).toBe(total);
49+
});
50+
51+
it('rejects basis points that do not sum to 10,000', () => {
52+
expect(() => splitStroops(1_000_000_000n, [3333, 3333, 3333])).toThrow(
53+
BadRequestException,
54+
);
55+
});
56+
});

src/escrow/split-math.util.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
3+
/** One hundred percent expressed in basis points (hundredths of a percent). */
4+
export const TOTAL_BASIS_POINTS = 10_000;
5+
6+
/**
7+
* Converts floating-point percentages into integer basis points that sum to
8+
* exactly {@link TOTAL_BASIS_POINTS} (100.00%).
9+
*
10+
* Naively rounding each percentage independently (`Math.round(p * 100)`) can
11+
* leave the total a few basis points off 10,000 (e.g. three-way splits at
12+
* repeating-decimal percentages), which would silently under- or over-fund
13+
* the contract. This apportions the leftover/overage to the recipients whose
14+
* independent rounding diverged the most, so the integer vector handed to the
15+
* contract always represents exactly 100%.
16+
*/
17+
export function apportionBasisPoints(percentages: number[]): number[] {
18+
if (percentages.length === 0) {
19+
throw new BadRequestException('At least one percentage is required');
20+
}
21+
22+
const bps = percentages.map((p) => Math.round(p * 100));
23+
const delta = TOTAL_BASIS_POINTS - bps.reduce((sum, b) => sum + b, 0);
24+
25+
// Distance between each rounded basis point and the exact quota. A positive
26+
// error means the recipient was rounded down (owed the leftover); negative
27+
// means rounded up (over-represented).
28+
const errors = percentages.map((p, i) => ({
29+
index: i,
30+
error: p * 100 - bps[i],
31+
}));
32+
33+
if (delta > 0) {
34+
errors.sort((a, b) => b.error - a.error || a.index - b.index);
35+
for (let i = 0; i < delta; i++) {
36+
bps[errors[i % errors.length].index] += 1;
37+
}
38+
} else if (delta < 0) {
39+
errors.sort((a, b) => a.error - b.error || a.index - b.index);
40+
for (let i = 0; i < -delta; i++) {
41+
bps[errors[i % errors.length].index] -= 1;
42+
}
43+
}
44+
45+
return bps;
46+
}
47+
48+
/**
49+
* Splits `totalStroops` into integer stroop shares proportional to `bps`
50+
* (which must sum to exactly {@link TOTAL_BASIS_POINTS}).
51+
*
52+
* The returned shares always sum to `totalStroops` exactly. Each share is
53+
* `floor(totalStroops * bps / 10000)`, with the leftover stroops handed out
54+
* by the largest-remainder method so no remainder is ever lost or invented.
55+
*/
56+
export function splitStroops(totalStroops: bigint, bps: number[]): bigint[] {
57+
if (bps.length === 0) {
58+
throw new BadRequestException('At least one recipient is required');
59+
}
60+
const bpsTotal = bps.reduce((sum, b) => sum + b, 0);
61+
if (bpsTotal !== TOTAL_BASIS_POINTS) {
62+
throw new BadRequestException(
63+
`Basis points must sum to ${TOTAL_BASIS_POINTS}, got ${bpsTotal}`,
64+
);
65+
}
66+
67+
const scale = BigInt(TOTAL_BASIS_POINTS);
68+
const shares = bps.map((b) => (totalStroops * BigInt(b)) / scale);
69+
const remainder = totalStroops - shares.reduce((sum, s) => sum + s, 0n);
70+
71+
const remainders = bps.map((b, i) => ({
72+
index: i,
73+
remainder: (totalStroops * BigInt(b)) % scale,
74+
}));
75+
76+
// Largest-remainder allocation: give the leftover stroops to the recipients
77+
// with the largest fractional remainder (ties broken by larger share, then
78+
// by original order).
79+
remainders.sort((a, b) => {
80+
if (a.remainder > b.remainder) return -1;
81+
if (a.remainder < b.remainder) return 1;
82+
if (shares[a.index] > shares[b.index]) return -1;
83+
if (shares[a.index] < shares[b.index]) return 1;
84+
return a.index - b.index;
85+
});
86+
87+
for (let i = 0; i < Number(remainder); i++) {
88+
shares[remainders[i % remainders.length].index] += 1n;
89+
}
90+
91+
return shares;
92+
}

0 commit comments

Comments
 (0)