From 768627d63efd5c0d3fece9660ee7d257c32ee01d Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:35:21 +0100 Subject: [PATCH 01/16] Refactor Escrow ID generation to use robust UUIDs --- backend/src/escrow/escrow.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 83aa084..f90a513 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -36,7 +36,7 @@ export class EscrowService { private escrows: Map = new Map(); async create(depositor: string, beneficiary: string, amountXLM: string): Promise { - const id = `esc-${Date.now()}-${randomUUID().slice(0, 8)}`; + const id = randomUUID(); const escrow: Escrow = { id, depositor, From 831de0c795ee504668ca049c521c170c968ee26f Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:35:38 +0100 Subject: [PATCH 02/16] Update createFromChainState ID generation to use randomUUID --- backend/src/escrow/escrow.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index f90a513..863e02c 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -89,7 +89,7 @@ export class EscrowService { /** Creates a DB row for an escrow found on-chain but never recorded (e.g. a missed creation event). */ async createFromChainState(seed: ChainEscrowSeed): Promise { - const id = `esc-${Date.now()}-${randomUUID().slice(0, 8)}`; + const id = randomUUID(); const escrow: Escrow = { id, depositor: seed.depositor, From 410561a3b3c20deca0d9ac6aedceda2366d84689 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:35:54 +0100 Subject: [PATCH 03/16] Add concurrency regression test for escrow ID generation --- backend/src/escrow/escrow.service.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index ca771e2..fe1061c 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -7,6 +7,21 @@ describe('EscrowService', () => { service = new EscrowService(); }); + describe('create', () => { + it('generates unique IDs even when called concurrently in a tight loop', async () => { + const numEscrows = 1000; + const promises = []; + for (let i = 0; i < numEscrows; i++) { + promises.push(service.create(`GDEP${i}`, `GBEN${i}`, '100')); + } + + const escrows = await Promise.all(promises); + const ids = new Set(escrows.map(e => e.id)); + + expect(ids.size).toBe(numEscrows); + }); + }); + describe('findAll', () => { it('returns every tracked escrow', async () => { await service.create('GDEP1', 'GBEN1', '100'); From b25e9feec7be3ff7d3113f7a8dff4928b446cd72 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:36:10 +0100 Subject: [PATCH 04/16] Enhance ID generation test to verify UUID format string --- backend/src/escrow/escrow.service.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index fe1061c..b4526ad 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -19,6 +19,10 @@ describe('EscrowService', () => { const ids = new Set(escrows.map(e => e.id)); expect(ids.size).toBe(numEscrows); + + // Verify UUID format (basic check) + const sampleId = escrows[0].id; + expect(sampleId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); }); }); From 3bdcd6793c736a3ad73b59192182d13db05f33e7 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:36:25 +0100 Subject: [PATCH 05/16] Add guarded fund method to EscrowService --- backend/src/escrow/escrow.service.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 863e02c..7384e59 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -103,6 +103,16 @@ export class EscrowService { return escrow; } + async fund(id: string): Promise { + const escrow = this.escrows.get(id); + if (!escrow) throw new Error('Escrow not found'); + if (escrow.status !== 'pending') { + throw new Error(`Cannot fund escrow in status: ${escrow.status}`); + } + escrow.status = 'active'; + return escrow; + } + async release(id: string): Promise { const escrow = this.escrows.get(id); if (!escrow) throw new Error('Escrow not found'); From c23ae5767cdb6b0a438e79b2056d9441565784b7 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:36:39 +0100 Subject: [PATCH 06/16] Update EventProcessorService to use guarded EscrowService.fund() --- backend/src/event-ingestion/event-processor.service.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/src/event-ingestion/event-processor.service.ts b/backend/src/event-ingestion/event-processor.service.ts index 53dd29b..3956d53 100644 --- a/backend/src/event-ingestion/event-processor.service.ts +++ b/backend/src/event-ingestion/event-processor.service.ts @@ -94,11 +94,8 @@ export class EventProcessorService { private async handleEscrowFunded(event: SorobanEvent): Promise { const escrowId = event.topic[1]; - const escrow = await this.escrowService.findById(escrowId); - if (escrow) { - escrow.status = 'active'; - this.logger.log(`Escrow funded: ${escrowId}`); - } + await this.escrowService.fund(escrowId); + this.logger.log(`Escrow funded: ${escrowId}`); } private async handleEscrowReleased(event: SorobanEvent): Promise { From 162d75d9f643a8eba9d7e8c89a817441cca9ba43 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:37:06 +0100 Subject: [PATCH 07/16] Add tests for EventProcessorService handling escrow_funded events --- .../event-processor.service.spec.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/backend/src/event-ingestion/event-processor.service.spec.ts b/backend/src/event-ingestion/event-processor.service.spec.ts index 5471972..a73394a 100644 --- a/backend/src/event-ingestion/event-processor.service.spec.ts +++ b/backend/src/event-ingestion/event-processor.service.spec.ts @@ -10,6 +10,7 @@ describe('EventProcessorService', () => { findById: jest.fn().mockResolvedValue({ id: 'esc-123', status: 'pending' }), release: jest.fn().mockResolvedValue({ id: 'esc-123', status: 'released' }), raiseDispute: jest.fn().mockResolvedValue({ id: 'esc-123', status: 'disputed' }), + fund: jest.fn().mockResolvedValue({ id: 'esc-123', status: 'active' }), }; beforeEach(async () => { @@ -67,6 +68,24 @@ describe('EventProcessorService', () => { expect(mockEscrowService.release).toHaveBeenCalledWith('esc-123'); }); + it('should process escrow_funded event', async () => { + const event: SorobanEvent = { + id: 'event-funded', + ledger: 101, + contractId: 'test-contract', + eventType: 'escrow_funded', + topic: ['escrow_funded', 'esc-123'], + value: {}, + xdr: 'test-xdr', + createdAt: new Date(), + }; + + const result = await service.processEvent(event); + + expect(result.success).toBe(true); + expect(mockEscrowService.fund).toHaveBeenCalledWith('esc-123'); + }); + it('should process escrow_disputed event', async () => { const event: SorobanEvent = { id: 'event-3', From dfebe89db31290fade16a7d1c9e6f377bc95f185 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:37:24 +0100 Subject: [PATCH 08/16] Add tests for EscrowService.fund() covering gracefully rejected replayed events --- backend/src/escrow/escrow.service.spec.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index b4526ad..b3c65e5 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -41,6 +41,27 @@ describe('EscrowService', () => { }); }); + describe('fund', () => { + it('updates status to active for a pending escrow', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + const updated = await service.fund(escrow.id); + expect(updated.status).toBe('active'); + }); + + it('throws when trying to fund an already active escrow', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + await service.fund(escrow.id); + await expect(service.fund(escrow.id)).rejects.toThrow('Cannot fund escrow in status: active'); + }); + + it('throws when trying to fund a disputed escrow', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + await service.fund(escrow.id); // Must be active to dispute? No, pending can be disputed based on current logic. Wait, let's just make it disputed using applyChainState. + await service.applyChainState(escrow.id, { status: 'disputed' }); + await expect(service.fund(escrow.id)).rejects.toThrow('Cannot fund escrow in status: disputed'); + }); + }); + describe('linkContractEscrowId / findByContractEscrowId', () => { it('links a DB row to its on-chain id and finds it back by that id', async () => { const escrow = await service.create('GDEP', 'GBEN', '100'); From be82ff1f52333384347879bebf3e021e65dc65c1 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:37:52 +0100 Subject: [PATCH 09/16] Add cancel and split methods to EscrowService for dispute payouts --- backend/src/escrow/escrow.service.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 7384e59..8bc8a97 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -14,6 +14,7 @@ export interface Escrow { disputedAt?: string; /** On-chain escrow identifier, set once this row is linked to its contract counterpart. */ contractEscrowId?: string; + splitPercentage?: number; } /** Chain-verified fields the reconciler may write when repairing drift. */ @@ -120,6 +121,21 @@ export class EscrowService { return escrow; } + async cancel(id: string): Promise { + const escrow = this.escrows.get(id); + if (!escrow) throw new Error('Escrow not found'); + escrow.status = 'cancelled'; + return escrow; + } + + async split(id: string, splitPercentage: number): Promise { + const escrow = this.escrows.get(id); + if (!escrow) throw new Error('Escrow not found'); + escrow.status = 'released'; + escrow.splitPercentage = splitPercentage; + return escrow; + } + async raiseDispute(id: string, reason?: string): Promise { const escrow = this.escrows.get(id); if (!escrow) throw new Error('Escrow not found'); From 207b8263e5efa175c5d37ea7a1aa15e26cf05696 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:38:08 +0100 Subject: [PATCH 10/16] Update DisputeSagaService to use explicit cancel/split methods instead of ad-hoc mutations --- backend/src/dispute/dispute-saga.service.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts index 94da0c4..66080d1 100644 --- a/backend/src/dispute/dispute-saga.service.ts +++ b/backend/src/dispute/dispute-saga.service.ts @@ -426,24 +426,11 @@ export class DisputeSagaService { break; case DisputeVerdict.DEPOSITOR_WINS: - // Funds returned to depositor — mark as cancelled - { - const escrow = await this.escrowService.findById(saga.escrowId); - if (escrow) escrow.status = 'cancelled'; - } + await this.escrowService.cancel(saga.escrowId); break; case DisputeVerdict.SPLIT: - // Partial release — use provided split or default 50/50 - { - const escrow = await this.escrowService.findById(saga.escrowId); - if (escrow) { - // Record split metadata; actual on-chain split would be handled by Soroban contract - (escrow as Escrow & { splitPercentage?: number }).splitPercentage = - splitPercentage ?? 50; - escrow.status = 'released'; - } - } + await this.escrowService.split(saga.escrowId, splitPercentage ?? 50); break; } } From 4e7d3f8359bc008879f3629f31fb23b846bbdc6b Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:38:25 +0100 Subject: [PATCH 11/16] Add tests for EscrowService cancel and split methods --- backend/src/escrow/escrow.service.spec.ts | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index b3c65e5..f43e16e 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -62,6 +62,31 @@ describe('EscrowService', () => { }); }); + describe('cancel', () => { + it('updates status to cancelled', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + const updated = await service.cancel(escrow.id); + expect(updated.status).toBe('cancelled'); + }); + + it('throws when escrow does not exist', async () => { + await expect(service.cancel('esc-missing')).rejects.toThrow('Escrow not found'); + }); + }); + + describe('split', () => { + it('updates status to released and sets split percentage', async () => { + const escrow = await service.create('GDEP', 'GBEN', '100'); + const updated = await service.split(escrow.id, 70); + expect(updated.status).toBe('released'); + expect(updated.splitPercentage).toBe(70); + }); + + it('throws when escrow does not exist', async () => { + await expect(service.split('esc-missing', 50)).rejects.toThrow('Escrow not found'); + }); + }); + describe('linkContractEscrowId / findByContractEscrowId', () => { it('links a DB row to its on-chain id and finds it back by that id', async () => { const escrow = await service.create('GDEP', 'GBEN', '100'); From f6cd7b1a6877e4df432a423aa2f1da0689abc442 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:39:57 +0100 Subject: [PATCH 12/16] Update DisputeSagaService tests to verify correct EscrowService method calls for payout --- .../src/dispute/dispute-saga.service.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index babfa15..32506ef 100644 --- a/backend/src/dispute/dispute-saga.service.spec.ts +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -35,6 +35,15 @@ function buildMocks() { escrow.status = 'released'; return escrow; }), + cancel: jest.fn().mockImplementation(async () => { + escrow.status = 'cancelled'; + return escrow; + }), + split: jest.fn().mockImplementation(async (_id: string, splitPercentage: number) => { + escrow.status = 'released'; + (escrow as any).splitPercentage = splitPercentage; + return escrow; + }), }; const webhookService = { dispatch: jest.fn().mockResolvedValue(undefined) }; @@ -305,12 +314,13 @@ describe('DisputeSagaService', () => { await service.castVote(sagaId, { jurorAddress: JURORS[2], vote: 'depositor' }); } - it('completes saga and sets COMPLETED for DEPOSITOR_WINS', async () => { + it('completes saga and calls escrowService.cancel for DEPOSITOR_WINS', async () => { await runToPayoutStep('depositor'); const saga = await service.executePayout(sagaId, {}); expect(saga.currentStep).toBe(DisputeStep.COMPLETED); expect(saga.payoutTxHash).toBeDefined(); expect(saga.completedAt).toBeDefined(); + expect(escrowService.cancel).toHaveBeenCalledWith('esc-001'); }); it('releases escrow for BENEFICIARY_WINS', async () => { @@ -319,6 +329,12 @@ describe('DisputeSagaService', () => { expect(escrowService.release).toHaveBeenCalledWith('esc-001'); }); + it('calls escrowService.split with correct percentage for SPLIT', async () => { + await runToPayoutStep('split'); + await service.executePayout(sagaId, { splitPercentage: 70 }); + expect(escrowService.split).toHaveBeenCalledWith('esc-001', 70); + }); + it('dispatches payout_executed and saga_completed webhooks', async () => { await runToPayoutStep('depositor'); await service.executePayout(sagaId, {}); From de7d073fb5184d3c6f9d21d848f2f5a61af85ad2 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:40:13 +0100 Subject: [PATCH 13/16] Allow re-escalation for an escrow if previous saga is COMPLETED --- backend/src/dispute/dispute-saga.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts index 66080d1..543d5d7 100644 --- a/backend/src/dispute/dispute-saga.service.ts +++ b/backend/src/dispute/dispute-saga.service.ts @@ -84,7 +84,11 @@ export class DisputeSagaService { async escalate(escrowId: string, dto: EscalateDisputeDto): Promise { // Guard: only one active saga per escrow const existing = this.findByEscrowId(escrowId); - if (existing && existing.currentStep !== DisputeStep.FAILED) { + if ( + existing && + existing.currentStep !== DisputeStep.FAILED && + existing.currentStep !== DisputeStep.COMPLETED + ) { throw new ConflictException(`An active dispute saga already exists for escrow ${escrowId}`); } From 6d68adb6f93ab75d402881b91f861944dafaa7db Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:41:01 +0100 Subject: [PATCH 14/16] Add tests ensuring completed sagas do not block re-escalation --- backend/src/dispute/dispute-saga.service.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index 32506ef..49183b3 100644 --- a/backend/src/dispute/dispute-saga.service.spec.ts +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -128,6 +128,16 @@ describe('DisputeSagaService', () => { expect(escrowService.raiseDispute).toHaveBeenCalledWith('esc-001', ESCALATE_DTO.reason); }); + it('allows re-escalation if the previous saga is COMPLETED', async () => { + const firstSaga = await service.escalate('esc-001', ESCALATE_DTO); + // Simulate completion + firstSaga.currentStep = DisputeStep.COMPLETED; + + const secondSaga = await service.escalate('esc-001', ESCALATE_DTO); + expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId); + expect(secondSaga.escrowId).toBe('esc-001'); + }); + it('records ESCALATION in stepHistory as completed', async () => { const saga = await service.escalate('esc-001', ESCALATE_DTO); const record = saga.stepHistory.find(r => r.step === DisputeStep.ESCALATION); From 47ac21241ed8974d0f93c7a719aa2b27db9ae915 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:41:24 +0100 Subject: [PATCH 15/16] Verify old completed sagas remain accessible after re-escalation --- backend/src/dispute/dispute-saga.service.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index 49183b3..4f58457 100644 --- a/backend/src/dispute/dispute-saga.service.spec.ts +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -136,6 +136,10 @@ describe('DisputeSagaService', () => { const secondSaga = await service.escalate('esc-001', ESCALATE_DTO); expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId); expect(secondSaga.escrowId).toBe('esc-001'); + + // Verify old saga is still tracked + const retrievedFirst = service.findById(firstSaga.sagaId); + expect(retrievedFirst.currentStep).toBe(DisputeStep.COMPLETED); }); it('records ESCALATION in stepHistory as completed', async () => { From ae8306111e9f0c4a45c09589fdb319805d440b76 Mon Sep 17 00:00:00 2001 From: Theophilus Adesola Date: Sun, 30 Aug 2026 01:41:49 +0100 Subject: [PATCH 16/16] Add tests ensuring failed sagas do not block re-escalation --- backend/src/dispute/dispute-saga.service.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index 4f58457..359ff5d 100644 --- a/backend/src/dispute/dispute-saga.service.spec.ts +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -142,6 +142,16 @@ describe('DisputeSagaService', () => { expect(retrievedFirst.currentStep).toBe(DisputeStep.COMPLETED); }); + it('allows re-escalation if the previous saga is FAILED', async () => { + const firstSaga = await service.escalate('esc-001', ESCALATE_DTO); + // Simulate failure + firstSaga.currentStep = DisputeStep.FAILED; + + const secondSaga = await service.escalate('esc-001', ESCALATE_DTO); + expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId); + expect(secondSaga.escrowId).toBe('esc-001'); + }); + it('records ESCALATION in stepHistory as completed', async () => { const saga = await service.escalate('esc-001', ESCALATE_DTO); const record = saga.stepHistory.find(r => r.step === DisputeStep.ESCALATION);