diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts index babfa15..359ff5d 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) }; @@ -119,6 +128,30 @@ 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'); + + // Verify old saga is still tracked + const retrievedFirst = service.findById(firstSaga.sagaId); + 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); @@ -305,12 +338,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 +353,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, {}); diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts index 94da0c4..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}`); } @@ -426,24 +430,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; } } diff --git a/backend/src/escrow/escrow.service.spec.ts b/backend/src/escrow/escrow.service.spec.ts index ca771e2..f43e16e 100644 --- a/backend/src/escrow/escrow.service.spec.ts +++ b/backend/src/escrow/escrow.service.spec.ts @@ -7,6 +7,25 @@ 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); + + // 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); + }); + }); + describe('findAll', () => { it('returns every tracked escrow', async () => { await service.create('GDEP1', 'GBEN1', '100'); @@ -22,6 +41,52 @@ 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('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'); diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 83aa084..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. */ @@ -36,7 +37,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, @@ -89,7 +90,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, @@ -103,6 +104,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'); @@ -110,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'); 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', 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 {