@@ -3,7 +3,11 @@ import { getRepositoryToken } from '@nestjs/typeorm';
33import { BadRequestException } from '@nestjs/common' ;
44import { DataSource } from 'typeorm' ;
55import { EscrowService } from './escrow.service' ;
6- import { SorobanClientService , u64 } from './soroban-client.service' ;
6+ import {
7+ SorobanClientService ,
8+ SorobanPendingError ,
9+ u64 ,
10+ } from './soroban-client.service' ;
711import { Escrow , Payment , User } from '../common/entities' ;
812import { AssetType , EscrowStatus , PaymentStatus } from '../common/enums' ;
913import { TOTAL_BASIS_POINTS } from './split-math.util' ;
@@ -75,9 +79,7 @@ describe('EscrowService', () => {
7579 entity === Escrow ? escrowRepo . findOne ( options ) : Promise . resolve ( null ) ,
7680 ) ,
7781 find : jest . fn ( ( entity : unknown , options : unknown ) =>
78- entity === Payment
79- ? paymentRepo . find ( options )
80- : Promise . resolve ( [ ] ) ,
82+ entity === Payment ? paymentRepo . find ( options ) : Promise . resolve ( [ ] ) ,
8183 ) ,
8284 save : jest . fn ( ( entity : unknown , data : unknown ) =>
8385 entity === Payment ? paymentRepo . save ( data ) : escrowRepo . save ( data ) ,
@@ -221,6 +223,29 @@ describe('EscrowService', () => {
221223 ) ;
222224 } ) ;
223225
226+ it ( 'keeps the escrow PENDING with the tx hash when the poll times out (#527)' , async ( ) => {
227+ soroban . invoke . mockRejectedValueOnce ( new SorobanPendingError ( 'tx-abc' ) ) ;
228+
229+ await expect (
230+ service . fund ( {
231+ amount : '10' ,
232+ asset : AssetType . XLM ,
233+ funderAddress : 'G...' ,
234+ bountyId : 'bounty-1' ,
235+ } ) ,
236+ ) . rejects . toBeInstanceOf ( SorobanPendingError ) ;
237+
238+ const savedCalls = (
239+ escrowRepo . save . mock . calls as [ Partial < Escrow > ] [ ]
240+ ) . map ( ( c ) => c [ 0 ] ) ;
241+ const last = savedCalls [ savedCalls . length - 1 ] ;
242+ expect ( last . status ) . toBe ( EscrowStatus . PENDING ) ;
243+ expect ( last . fundTxHash ) . toBe ( 'tx-abc' ) ;
244+ expect ( savedCalls . every ( ( e ) => e . status !== EscrowStatus . FAILED ) ) . toBe (
245+ true ,
246+ ) ;
247+ } ) ;
248+
224249 it ( 'keeps the escrow LOCKED when the on-chain call succeeded but the local write failed (#302, #42)' , async ( ) => {
225250 // First save (the PENDING row) succeeds; the post-invoke save fails —
226251 // exactly the post-on-chain-success failure #42 describes.
@@ -241,12 +266,12 @@ describe('EscrowService', () => {
241266 const savedCalls = (
242267 escrowRepo . save . mock . calls as [ Partial < Escrow > ] [ ]
243268 ) . map ( ( c ) => c [ 0 ] ) ;
244- expect (
245- savedCalls . some ( ( e ) => e . status === EscrowStatus . FAILED ) ,
246- ) . toBe ( false ) ;
247- expect (
248- savedCalls . every ( ( e ) => e . status !== EscrowStatus . FAILED ) ,
249- ) . toBe ( true ) ;
269+ expect ( savedCalls . some ( ( e ) => e . status === EscrowStatus . FAILED ) ) . toBe (
270+ false ,
271+ ) ;
272+ expect ( savedCalls . every ( ( e ) => e . status !== EscrowStatus . FAILED ) ) . toBe (
273+ true ,
274+ ) ;
250275
251276 // The retry records the reconciliation marker (and keeps the LOCKED
252277 // status + tx hash) so a reconciliation job can find the row.
@@ -278,9 +303,9 @@ describe('EscrowService', () => {
278303 const savedCalls = (
279304 escrowRepo . save . mock . calls as [ Partial < Escrow > ] [ ]
280305 ) . map ( ( c ) => c [ 0 ] ) ;
281- expect (
282- savedCalls . some ( ( e ) => e . status === EscrowStatus . FAILED ) ,
283- ) . toBe ( false ) ;
306+ expect ( savedCalls . some ( ( e ) => e . status === EscrowStatus . FAILED ) ) . toBe (
307+ false ,
308+ ) ;
284309 } ) ;
285310
286311 it . each ( [
@@ -572,15 +597,18 @@ describe('EscrowService', () => {
572597 escrowRepo . findOne . mockResolvedValue ( { ...lockedEscrow } ) ;
573598 paymentRepo . find . mockResolvedValue ( [ ] ) ;
574599 const order : string [ ] = [ ] ;
575- dataSource . manager . findOne . mockImplementation (
576- async ( ) => {
577- order . push ( 'locked-read' ) ;
578- return { ...lockedEscrow } ;
579- } ,
580- ) ;
600+ dataSource . manager . findOne . mockImplementation ( async ( ) => {
601+ order . push ( 'locked-read' ) ;
602+ return { ...lockedEscrow } ;
603+ } ) ;
581604 soroban . invoke . mockImplementation ( async ( ) => {
582605 order . push ( 'invoke' ) ;
583- return { txHash : 'tx-1' , ledger : 1 , returnValue : null , status : 'SUCCESS' } ;
606+ return {
607+ txHash : 'tx-1' ,
608+ ledger : 1 ,
609+ returnValue : null ,
610+ status : 'SUCCESS' ,
611+ } ;
584612 } ) ;
585613 paymentRepo . save . mockImplementation ( async ( data : Partial < Payment > ) => {
586614 order . push ( 'payment-write' ) ;
@@ -834,7 +862,9 @@ describe('EscrowService', () => {
834862 it ( 'records a failed withdraw via the invokeOnLockedEscrow failure path (#89) and keeps the escrow LOCKED' , async ( ) => {
835863 const escrow = lockedPoolEscrow ( ) ;
836864 escrowRepo . findOne . mockResolvedValue ( escrow ) ;
837- soroban . invoke . mockRejectedValueOnce ( new Error ( 'withdraw simulation failed' ) ) ;
865+ soroban . invoke . mockRejectedValueOnce (
866+ new Error ( 'withdraw simulation failed' ) ,
867+ ) ;
838868
839869 await expect (
840870 service . poolWithdraw ( 'escrow-pool' , '10.0000000' , 'GRECIPIENT' ) ,
0 commit comments