Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
768627d
Refactor Escrow ID generation to use robust UUIDs
nursetechie Aug 30, 2026
831de0c
Update createFromChainState ID generation to use randomUUID
nursetechie Aug 30, 2026
410561a
Add concurrency regression test for escrow ID generation
nursetechie Aug 30, 2026
b25e9fe
Enhance ID generation test to verify UUID format string
nursetechie Aug 30, 2026
3bdcd67
Add guarded fund method to EscrowService
nursetechie Aug 30, 2026
c23ae57
Update EventProcessorService to use guarded EscrowService.fund()
nursetechie Aug 30, 2026
162d75d
Add tests for EventProcessorService handling escrow_funded events
nursetechie Aug 30, 2026
dfebe89
Add tests for EscrowService.fund() covering gracefully rejected repla…
nursetechie Aug 30, 2026
be82ff1
Add cancel and split methods to EscrowService for dispute payouts
nursetechie Aug 30, 2026
207b826
Update DisputeSagaService to use explicit cancel/split methods instea…
nursetechie Aug 30, 2026
4e7d3f8
Add tests for EscrowService cancel and split methods
nursetechie Aug 30, 2026
f6cd7b1
Update DisputeSagaService tests to verify correct EscrowService metho…
nursetechie Aug 30, 2026
de7d073
Allow re-escalation for an escrow if previous saga is COMPLETED
nursetechie Aug 30, 2026
6d68adb
Add tests ensuring completed sagas do not block re-escalation
nursetechie Aug 30, 2026
47ac212
Verify old completed sagas remain accessible after re-escalation
nursetechie Aug 30, 2026
ae83061
Add tests ensuring failed sagas do not block re-escalation
nursetechie Aug 30, 2026
ef48b62
Merge branch 'main' into feat/escrow-dispute-enhancements
nursetechie Aug 30, 2026
a0aa810
Merge branch 'main' into feat/escrow-dispute-enhancements
meshackyaro Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion backend/src/dispute/dispute-saga.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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, {});
Expand Down
23 changes: 7 additions & 16 deletions backend/src/dispute/dispute-saga.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ export class DisputeSagaService {
async escalate(escrowId: string, dto: EscalateDisputeDto): Promise<DisputeSaga> {
// 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}`);
}

Expand Down Expand Up @@ -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;
}
}
Expand Down
65 changes: 65 additions & 0 deletions backend/src/escrow/escrow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
30 changes: 28 additions & 2 deletions backend/src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -36,7 +37,7 @@ export class EscrowService {
private escrows: Map<string, Escrow> = new Map();

async create(depositor: string, beneficiary: string, amountXLM: string): Promise<Escrow> {
const id = `esc-${Date.now()}-${randomUUID().slice(0, 8)}`;
const id = randomUUID();
const escrow: Escrow = {
id,
depositor,
Expand Down Expand Up @@ -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<Escrow> {
const id = `esc-${Date.now()}-${randomUUID().slice(0, 8)}`;
const id = randomUUID();
const escrow: Escrow = {
id,
depositor: seed.depositor,
Expand All @@ -103,13 +104,38 @@ export class EscrowService {
return escrow;
}

async fund(id: string): Promise<Escrow> {
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<Escrow> {
const escrow = this.escrows.get(id);
if (!escrow) throw new Error('Escrow not found');
escrow.status = 'released';
return escrow;
}

async cancel(id: string): Promise<Escrow> {
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<Escrow> {
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<Escrow> {
const escrow = this.escrows.get(id);
if (!escrow) throw new Error('Escrow not found');
Expand Down
19 changes: 19 additions & 0 deletions backend/src/event-ingestion/event-processor.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand Down
7 changes: 2 additions & 5 deletions backend/src/event-ingestion/event-processor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,8 @@ export class EventProcessorService {

private async handleEscrowFunded(event: SorobanEvent): Promise<void> {
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<void> {
Expand Down