@@ -12,8 +12,10 @@ import {
1212 amountToStroops ,
1313 isSupportedEscrowAsset ,
1414 isValidMoneyAmount ,
15+ stroopsToAmount ,
1516} from '../common/validators/money.validator' ;
1617import { SorobanClientService } from './soroban-client.service' ;
18+ import { apportionBasisPoints , splitStroops } from './split-math.util' ;
1719
1820export 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 {
0 commit comments