diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 968dbaf..78b049a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -9,6 +9,7 @@ import { RedisModule } from './common/redis/redis.module'; import { RateLimitModule } from './common/rate-limit/rate-limit.module'; import { UserProfileModule } from './user-profile/user-profile.module'; import { EventIngestionModule } from './event-ingestion/event-ingestion.module'; +import { DisputeModule } from './dispute/dispute.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { EventIngestionModule } from './event-ingestion/event-ingestion.module'; MonitoringModule, StellarModule, EventIngestionModule, + DisputeModule, ], }) export class AppModule {} diff --git a/backend/src/dispute/dispute-saga.controller.ts b/backend/src/dispute/dispute-saga.controller.ts new file mode 100644 index 0000000..41289e0 --- /dev/null +++ b/backend/src/dispute/dispute-saga.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Post, + Get, + Param, + Body, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBearerAuth } from '@nestjs/swagger'; +import { DisputeSagaService } from './dispute-saga.service'; +import { + EscalateDisputeDto, + AssignJurorsDto, + CastVoteDto, + ExecutePayoutDto, + DisputeSagaResponseDto, +} from './dispute.dto'; +import { JwtAuthGuard } from '../auth/auth.guard'; + +@ApiTags('Dispute Resolution') +@ApiBearerAuth('JWT-auth') +@UseGuards(JwtAuthGuard) +@Controller('dispute') +export class DisputeSagaController { + constructor(private readonly sagaService: DisputeSagaService) {} + + @Get() + @ApiOperation({ summary: 'List all dispute sagas' }) + @ApiResponse({ status: 200, type: [DisputeSagaResponseDto] }) + findAll() { + return this.sagaService.findAll(); + } + + @Get(':sagaId') + @ApiOperation({ summary: 'Get a dispute saga by ID' }) + @ApiParam({ name: 'sagaId', example: 'saga-1234567890-abc' }) + @ApiResponse({ status: 200, type: DisputeSagaResponseDto }) + @ApiResponse({ status: 404, description: 'Saga not found' }) + findOne(@Param('sagaId') sagaId: string) { + return this.sagaService.findById(sagaId); + } + + @Get('escrow/:escrowId') + @ApiOperation({ summary: 'Get the active dispute saga for an escrow' }) + @ApiParam({ name: 'escrowId', example: 'esc-1234567890' }) + @ApiResponse({ status: 200, type: DisputeSagaResponseDto }) + findByEscrow(@Param('escrowId') escrowId: string) { + return this.sagaService.findByEscrowId(escrowId); + } + + @Post('escrow/:escrowId/escalate') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Step 1 — Escalate dispute', + description: + 'Opens a new dispute saga for the escrow. Freezes the escrow and notifies juror pool via Discord. ' + + 'Compensating action: restores escrow status to active if this step fails.', + }) + @ApiParam({ name: 'escrowId', example: 'esc-1234567890' }) + @ApiResponse({ status: 201, type: DisputeSagaResponseDto }) + @ApiResponse({ status: 400, description: 'Escrow already released' }) + @ApiResponse({ status: 404, description: 'Escrow not found' }) + @ApiResponse({ status: 409, description: 'Active saga already exists for this escrow' }) + escalate(@Param('escrowId') escrowId: string, @Body() dto: EscalateDisputeDto) { + return this.sagaService.escalate(escrowId, dto); + } + + @Post(':sagaId/assign-jurors') + @ApiOperation({ + summary: 'Step 2 — Assign jurors', + description: + 'Assigns 3–7 jurors to the dispute. ' + + 'Compensating action: clears the juror list and reverts the saga to the assignment step.', + }) + @ApiParam({ name: 'sagaId', example: 'saga-1234567890-abc' }) + @ApiResponse({ status: 200, type: DisputeSagaResponseDto }) + @ApiResponse({ status: 400, description: 'Saga not at JUROR_ASSIGNMENT step' }) + assignJurors(@Param('sagaId') sagaId: string, @Body() dto: AssignJurorsDto) { + return this.sagaService.assignJurors(sagaId, dto); + } + + @Post(':sagaId/vote') + @ApiOperation({ + summary: 'Step 3 — Cast a juror vote', + description: + 'Records a vote from an assigned juror. When all jurors have voted, the verdict is computed ' + + 'automatically via majority rule and the saga advances to the PAYOUT step. ' + + 'Compensating action: removes the vote if an error occurs mid-recording.', + }) + @ApiParam({ name: 'sagaId', example: 'saga-1234567890-abc' }) + @ApiResponse({ status: 200, type: DisputeSagaResponseDto }) + @ApiResponse({ status: 400, description: 'Juror not assigned or saga not at VOTING step' }) + @ApiResponse({ status: 409, description: 'Juror has already voted' }) + castVote(@Param('sagaId') sagaId: string, @Body() dto: CastVoteDto) { + return this.sagaService.castVote(sagaId, dto); + } + + @Post(':sagaId/payout') + @ApiOperation({ + summary: 'Step 4 — Execute payout', + description: + 'Releases funds based on the recorded verdict. For SPLIT verdicts, an optional ' + + 'splitPercentage (0–100, depositor share) can be provided; defaults to 50. ' + + 'Compensating action: re-flags the escrow as disputed and marks it for manual admin review.', + }) + @ApiParam({ name: 'sagaId', example: 'saga-1234567890-abc' }) + @ApiResponse({ status: 200, type: DisputeSagaResponseDto }) + @ApiResponse({ status: 400, description: 'No verdict or saga not at PAYOUT step' }) + executePayout(@Param('sagaId') sagaId: string, @Body() dto: ExecutePayoutDto) { + return this.sagaService.executePayout(sagaId, dto); + } +} diff --git a/backend/src/dispute/dispute-saga.service.spec.ts b/backend/src/dispute/dispute-saga.service.spec.ts new file mode 100644 index 0000000..02852e0 --- /dev/null +++ b/backend/src/dispute/dispute-saga.service.spec.ts @@ -0,0 +1,331 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; +import { DisputeSagaService } from './dispute-saga.service'; +import { DisputeStep, DisputeVerdict } from './dispute.types'; +import { EscrowService } from '../escrow/escrow.service'; +import { WebhookService } from '../webhook/webhook.service'; +import { DiscordService } from '../webhook/discord.service'; + +// ─── Shared mock factories ──────────────────────────────────────────────────── + +function makeEscrow(overrides: Partial = {}) { + return { + id: 'esc-001', + depositor: 'GDEPOSITOR111111111111111111111111111111111111111111111', + beneficiary: 'GBENEFICIARY1111111111111111111111111111111111111111111', + amountXLM: '100', + status: 'active', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +function buildMocks() { + const escrow = makeEscrow(); + + const escrowService = { + findById: jest.fn().mockResolvedValue(escrow), + raiseDispute: jest.fn().mockImplementation(async (_id: string, _reason: string) => { + escrow.status = 'disputed'; + return escrow; + }), + release: jest.fn().mockImplementation(async () => { + escrow.status = 'released'; + return escrow; + }), + }; + + const webhookService = { dispatch: jest.fn().mockResolvedValue(undefined) }; + const discordService = { notifyDisputeNeedsJurors: jest.fn().mockResolvedValue(undefined) }; + + return { escrow, escrowService, webhookService, discordService }; +} + +const JURORS = [ + 'GJUROR1111111111111111111111111111111111111111111111111111', + 'GJUROR2222222222222222222222222222222222222222222222222222', + 'GJUROR3333333333333333333333333333333333333333333333333333', +]; + +const ESCALATE_DTO = { + initiator: 'GDEPOSITOR111111111111111111111111111111111111111111111', + reason: 'Work was not delivered as agreed in the contract', +}; + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe('DisputeSagaService', () => { + let service: DisputeSagaService; + let escrowService: ReturnType['escrowService']; + let webhookService: ReturnType['webhookService']; + let discordService: ReturnType['discordService']; + let escrow: ReturnType['escrow']; + + beforeEach(async () => { + const mocks = buildMocks(); + escrowService = mocks.escrowService; + webhookService = mocks.webhookService; + discordService = mocks.discordService; + escrow = mocks.escrow; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DisputeSagaService, + { provide: EscrowService, useValue: escrowService }, + { provide: WebhookService, useValue: webhookService }, + { provide: DiscordService, useValue: discordService }, + ], + }).compile(); + + service = module.get(DisputeSagaService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + // ─── escalate ───────────────────────────────────────────────────── + + describe('escalate()', () => { + it('creates a saga and advances to JUROR_ASSIGNMENT', async () => { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + + expect(saga.sagaId).toMatch(/^saga-/); + expect(saga.escrowId).toBe('esc-001'); + expect(saga.currentStep).toBe(DisputeStep.JUROR_ASSIGNMENT); + expect(saga.escalationTxHash).toBeDefined(); + expect(escrowService.raiseDispute).toHaveBeenCalledWith('esc-001', ESCALATE_DTO.reason); + }); + + 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); + expect(record?.completedAt).toBeDefined(); + expect(record?.failedAt).toBeUndefined(); + }); + + it('dispatches escalation webhook', async () => { + await service.escalate('esc-001', ESCALATE_DTO); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'dispute.escalated', + expect.objectContaining({ escrowId: 'esc-001' }), + ); + }); + + it('notifies Discord', async () => { + await service.escalate('esc-001', ESCALATE_DTO); + expect(discordService.notifyDisputeNeedsJurors).toHaveBeenCalledWith( + expect.objectContaining({ escrowId: 'esc-001' }), + ); + }); + + it('throws NotFoundException when escrow does not exist', async () => { + escrowService.findById.mockResolvedValueOnce(undefined); + await expect(service.escalate('esc-999', ESCALATE_DTO)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when escrow is already released', async () => { + escrowService.findById.mockResolvedValueOnce(makeEscrow({ status: 'released' })); + await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow(BadRequestException); + }); + + it('throws ConflictException when an active saga already exists', async () => { + await service.escalate('esc-001', ESCALATE_DTO); + // Reset the mock so raiseDispute doesn't double-throw + escrowService.raiseDispute.mockResolvedValue(escrow); + await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow(ConflictException); + }); + + it('compensates and marks FAILED when raiseDispute throws', async () => { + escrowService.raiseDispute.mockRejectedValueOnce(new Error('on-chain error')); + await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow('on-chain error'); + // No saga stored — compensation cleaned up + expect(service.findAll().filter(s => s.currentStep !== DisputeStep.FAILED).length).toBe(0); + }); + }); + + // ─── assignJurors ───────────────────────────────────────────────── + + describe('assignJurors()', () => { + let sagaId: string; + + beforeEach(async () => { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + sagaId = saga.sagaId; + }); + + it('assigns jurors and advances to VOTING', async () => { + const saga = await service.assignJurors(sagaId, { jurors: JURORS }); + expect(saga.currentStep).toBe(DisputeStep.VOTING); + expect(saga.assignedJurors).toEqual(JURORS); + }); + + it('deduplicates juror addresses', async () => { + const saga = await service.assignJurors(sagaId, { + jurors: [JURORS[0], JURORS[0], JURORS[1], JURORS[2]], + }); + expect(saga.assignedJurors?.length).toBe(3); + }); + + it('throws BadRequestException when fewer than 3 distinct jurors provided', async () => { + await expect( + service.assignJurors(sagaId, { jurors: [JURORS[0], JURORS[0], JURORS[0]] }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws BadRequestException when saga is at wrong step', async () => { + // Move past JUROR_ASSIGNMENT + await service.assignJurors(sagaId, { jurors: JURORS }); + await expect(service.assignJurors(sagaId, { jurors: JURORS })).rejects.toThrow( + BadRequestException, + ); + }); + + it('dispatches jurors_assigned webhook', async () => { + await service.assignJurors(sagaId, { jurors: JURORS }); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'dispute.jurors_assigned', + expect.objectContaining({ jurors: JURORS }), + ); + }); + }); + + // ─── castVote ───────────────────────────────────────────────────── + + describe('castVote()', () => { + let sagaId: string; + + beforeEach(async () => { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + sagaId = saga.sagaId; + await service.assignJurors(sagaId, { jurors: JURORS }); + }); + + it('records a vote', async () => { + const saga = await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'depositor' }); + expect(saga.votes?.length).toBe(1); + }); + + it('computes DEPOSITOR_WINS verdict when majority votes depositor', async () => { + await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'depositor' }); + await service.castVote(sagaId, { jurorAddress: JURORS[1], vote: 'depositor' }); + const saga = await service.castVote(sagaId, { + jurorAddress: JURORS[2], + vote: 'beneficiary', + }); + expect(saga.verdict).toBe(DisputeVerdict.DEPOSITOR_WINS); + expect(saga.currentStep).toBe(DisputeStep.PAYOUT); + }); + + it('computes BENEFICIARY_WINS verdict', async () => { + await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'beneficiary' }); + await service.castVote(sagaId, { jurorAddress: JURORS[1], vote: 'beneficiary' }); + const saga = await service.castVote(sagaId, { jurorAddress: JURORS[2], vote: 'depositor' }); + expect(saga.verdict).toBe(DisputeVerdict.BENEFICIARY_WINS); + }); + + it('computes SPLIT verdict when no majority', async () => { + await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'depositor' }); + await service.castVote(sagaId, { jurorAddress: JURORS[1], vote: 'beneficiary' }); + const saga = await service.castVote(sagaId, { jurorAddress: JURORS[2], vote: 'split' }); + expect(saga.verdict).toBe(DisputeVerdict.SPLIT); + }); + + it('throws BadRequestException when address is not an assigned juror', async () => { + await expect( + service.castVote(sagaId, { + jurorAddress: 'GNOTAJUROR11111111111111111111111111111111111111111111111', + vote: 'depositor', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('throws ConflictException on duplicate vote', async () => { + await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'depositor' }); + await expect( + service.castVote(sagaId, { jurorAddress: JURORS[0], vote: 'beneficiary' }), + ).rejects.toThrow(ConflictException); + }); + }); + + // ─── executePayout ──────────────────────────────────────────────── + + describe('executePayout()', () => { + let sagaId: string; + + async function runToPayoutStep(verdict: 'depositor' | 'beneficiary' | 'split') { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + sagaId = saga.sagaId; + await service.assignJurors(sagaId, { jurors: JURORS }); + await service.castVote(sagaId, { jurorAddress: JURORS[0], vote: verdict }); + await service.castVote(sagaId, { jurorAddress: JURORS[1], vote: verdict }); + await service.castVote(sagaId, { jurorAddress: JURORS[2], vote: 'depositor' }); + } + + it('completes saga and sets COMPLETED 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(); + }); + + it('releases escrow for BENEFICIARY_WINS', async () => { + await runToPayoutStep('beneficiary'); + await service.executePayout(sagaId, {}); + expect(escrowService.release).toHaveBeenCalledWith('esc-001'); + }); + + it('dispatches payout_executed and saga_completed webhooks', async () => { + await runToPayoutStep('depositor'); + await service.executePayout(sagaId, {}); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'dispute.payout_executed', + expect.objectContaining({ sagaId }), + ); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'dispute.saga_completed', + expect.objectContaining({ sagaId }), + ); + }); + + it('throws BadRequestException when called before PAYOUT step', async () => { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + await expect(service.executePayout(saga.sagaId, {})).rejects.toThrow(BadRequestException); + }); + + it('compensates on release failure and flags escrow for manual review', async () => { + await runToPayoutStep('beneficiary'); + escrowService.release.mockRejectedValueOnce(new Error('on-chain payout failed')); + escrowService.findById.mockResolvedValue({ ...escrow, status: 'disputed' }); + + await expect(service.executePayout(sagaId, {})).rejects.toThrow('on-chain payout failed'); + + const failed = service.findById(sagaId); + expect(failed.currentStep).toBe(DisputeStep.FAILED); + expect(webhookService.dispatch).toHaveBeenCalledWith( + 'dispute.saga_failed', + expect.objectContaining({ step: DisputeStep.PAYOUT }), + ); + }); + }); + + // ─── findById / findByEscrowId ──────────────────────────────────── + + describe('findById()', () => { + it('throws NotFoundException for unknown sagaId', () => { + expect(() => service.findById('saga-unknown')).toThrow(NotFoundException); + }); + }); + + describe('findByEscrowId()', () => { + it('returns undefined when no saga exists for escrow', () => { + expect(service.findByEscrowId('esc-999')).toBeUndefined(); + }); + + it('returns the saga when one exists', async () => { + const saga = await service.escalate('esc-001', ESCALATE_DTO); + expect(service.findByEscrowId('esc-001')?.sagaId).toBe(saga.sagaId); + }); + }); +}); diff --git a/backend/src/dispute/dispute-saga.service.ts b/backend/src/dispute/dispute-saga.service.ts new file mode 100644 index 0000000..f7ca13a --- /dev/null +++ b/backend/src/dispute/dispute-saga.service.ts @@ -0,0 +1,472 @@ +import { + Injectable, + Logger, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { + DisputeSaga, + DisputeStep, + DisputeVerdict, + JurorVote, + SagaStepRecord, +} from './dispute.types'; +import { EscalateDisputeDto, AssignJurorsDto, CastVoteDto, ExecutePayoutDto } from './dispute.dto'; +import { EscrowService, Escrow } from '../escrow/escrow.service'; +import { WebhookService } from '../webhook/webhook.service'; +import { DiscordService } from '../webhook/discord.service'; + +/** Webhook event names emitted by the saga */ +export const SAGA_EVENTS = { + ESCALATED: 'dispute.escalated', + JURORS_ASSIGNED: 'dispute.jurors_assigned', + VOTE_CAST: 'dispute.vote_cast', + VERDICT_REACHED: 'dispute.verdict_reached', + PAYOUT_EXECUTED: 'dispute.payout_executed', + SAGA_COMPLETED: 'dispute.saga_completed', + SAGA_COMPENSATING: 'dispute.saga_compensating', + SAGA_FAILED: 'dispute.saga_failed', +} as const; + +@Injectable() +export class DisputeSagaService { + private readonly logger = new Logger(DisputeSagaService.name); + /** In-memory saga store — keyed by sagaId */ + private readonly sagas: Map = new Map(); + /** Secondary index: escrowId → sagaId (one active saga per escrow) */ + private readonly escrowIndex: Map = new Map(); + + constructor( + private readonly escrowService: EscrowService, + private readonly webhookService: WebhookService, + private readonly discordService: DiscordService, + ) {} + + // ─── Queries ────────────────────────────────────────────────────── + + findById(sagaId: string): DisputeSaga { + const saga = this.sagas.get(sagaId); + if (!saga) throw new NotFoundException(`Dispute saga ${sagaId} not found`); + return saga; + } + + findByEscrowId(escrowId: string): DisputeSaga | undefined { + const sagaId = this.escrowIndex.get(escrowId); + return sagaId ? this.sagas.get(sagaId) : undefined; + } + + findAll(): DisputeSaga[] { + return [...this.sagas.values()]; + } + + // ─── Step 1: Escalation ─────────────────────────────────────────── + + /** + * Opens a new dispute saga for an escrow. + * Compensating action: restore escrow status to 'active'. + */ + 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) { + throw new ConflictException(`An active dispute saga already exists for escrow ${escrowId}`); + } + + const escrow = await this.escrowService.findById(escrowId); + if (!escrow) throw new NotFoundException(`Escrow ${escrowId} not found`); + if (escrow.status === 'released') { + throw new BadRequestException('Cannot dispute a released escrow'); + } + + const sagaId = `saga-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = new Date().toISOString(); + + const saga: DisputeSaga = { + sagaId, + escrowId, + initiator: dto.initiator, + reason: dto.reason, + currentStep: DisputeStep.ESCALATION, + votes: [], + stepHistory: [], + createdAt: now, + updatedAt: now, + }; + + this.recordStepStart(saga, DisputeStep.ESCALATION); + + try { + // Freeze the escrow by marking it disputed + await this.escrowService.raiseDispute(escrowId, dto.reason); + + // Simulate on-chain escalation tx hash + saga.escalationTxHash = `escalation-tx-${sagaId}`; + this.recordStepComplete(saga, DisputeStep.ESCALATION); + saga.currentStep = DisputeStep.JUROR_ASSIGNMENT; + + this.sagas.set(sagaId, saga); + this.escrowIndex.set(escrowId, sagaId); + this.touch(saga); + + await this.webhookService.dispatch(SAGA_EVENTS.ESCALATED, { sagaId, escrowId }); + await this.discordService.notifyDisputeNeedsJurors({ + escrowId, + depositor: escrow.depositor, + beneficiary: escrow.beneficiary, + amountXLM: escrow.amountXLM, + reason: dto.reason, + }); + + this.logger.log(`Saga ${sagaId}: escalation complete for escrow ${escrowId}`); + return saga; + } catch (error) { + await this.compensateEscalation(saga, error); + throw error; + } + } + + // ─── Compensating action for Step 1 ────────────────────────────── + + private async compensateEscalation(saga: DisputeSaga, error: unknown): Promise { + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn(`Saga ${saga.sagaId}: compensating escalation — ${reason}`); + this.recordStepFailed(saga, DisputeStep.ESCALATION, reason); + saga.currentStep = DisputeStep.COMPENSATING; + saga.compensationReason = reason; + + try { + // Compensating action: revert escrow status to active + const escrow = await this.escrowService.findById(saga.escrowId); + if (escrow && escrow.status === 'disputed') { + escrow.status = 'active'; + } + this.recordStepCompensated(saga, DisputeStep.ESCALATION); + } catch (compError) { + this.logger.error(`Saga ${saga.sagaId}: escalation compensation itself failed`, compError); + } + + this.markFailed(saga, reason); + await this.webhookService.dispatch(SAGA_EVENTS.SAGA_FAILED, { + sagaId: saga.sagaId, + reason, + step: DisputeStep.ESCALATION, + }); + } + + // ─── Step 2: Juror Assignment ───────────────────────────────────── + + /** + * Assigns jurors to review the dispute. + * Compensating action: clear juror list and re-open for assignment. + */ + async assignJurors(sagaId: string, dto: AssignJurorsDto): Promise { + const saga = this.findById(sagaId); + this.assertStep(saga, DisputeStep.JUROR_ASSIGNMENT); + + this.recordStepStart(saga, DisputeStep.JUROR_ASSIGNMENT); + + try { + // Deduplicate juror addresses + const unique = [...new Set(dto.jurors)]; + if (unique.length < 3) { + throw new BadRequestException('At least 3 distinct juror addresses are required'); + } + + saga.assignedJurors = unique; + this.recordStepComplete(saga, DisputeStep.JUROR_ASSIGNMENT); + saga.currentStep = DisputeStep.VOTING; + this.touch(saga); + + await this.webhookService.dispatch(SAGA_EVENTS.JURORS_ASSIGNED, { + sagaId, + jurors: unique, + }); + + this.logger.log(`Saga ${sagaId}: ${unique.length} jurors assigned`); + return saga; + } catch (error) { + await this.compensateJurorAssignment(saga, error); + throw error; + } + } + + // ─── Compensating action for Step 2 ────────────────────────────── + + private async compensateJurorAssignment(saga: DisputeSaga, error: unknown): Promise { + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn(`Saga ${saga.sagaId}: compensating juror assignment — ${reason}`); + this.recordStepFailed(saga, DisputeStep.JUROR_ASSIGNMENT, reason); + saga.currentStep = DisputeStep.COMPENSATING; + saga.compensationReason = reason; + + try { + // Compensating action: clear the assigned jurors, revert to prior step + saga.assignedJurors = undefined; + saga.currentStep = DisputeStep.JUROR_ASSIGNMENT; + this.recordStepCompensated(saga, DisputeStep.JUROR_ASSIGNMENT); + } catch (compError) { + this.logger.error(`Saga ${saga.sagaId}: juror assignment compensation failed`, compError); + } + + this.markFailed(saga, reason); + await this.webhookService.dispatch(SAGA_EVENTS.SAGA_COMPENSATING, { + sagaId: saga.sagaId, + step: DisputeStep.JUROR_ASSIGNMENT, + reason, + }); + } + + // ─── Step 3: Voting ─────────────────────────────────────────────── + + /** + * Records a juror vote. When all assigned jurors have voted, + * the verdict is computed automatically. + * Compensating action: remove the vote and mark voting as incomplete. + */ + async castVote(sagaId: string, dto: CastVoteDto): Promise { + const saga = this.findById(sagaId); + this.assertStep(saga, DisputeStep.VOTING); + + if (!saga.assignedJurors?.includes(dto.jurorAddress)) { + throw new BadRequestException(`${dto.jurorAddress} is not an assigned juror for this saga`); + } + + if (saga.votes?.some(v => v.jurorAddress === dto.jurorAddress)) { + throw new ConflictException(`Juror ${dto.jurorAddress} has already voted`); + } + + this.recordStepStart(saga, DisputeStep.VOTING); + + try { + const vote: JurorVote = { + jurorAddress: dto.jurorAddress, + vote: dto.vote, + castAt: new Date().toISOString(), + }; + saga.votes = [...(saga.votes ?? []), vote]; + this.touch(saga); + + await this.webhookService.dispatch(SAGA_EVENTS.VOTE_CAST, { + sagaId, + jurorAddress: dto.jurorAddress, + votesIn: saga.votes.length, + votesNeeded: saga.assignedJurors!.length, + }); + + // All jurors have voted — compute verdict + if (saga.votes.length === saga.assignedJurors!.length) { + const verdict = this.computeVerdict(saga.votes); + saga.verdict = verdict; + this.recordStepComplete(saga, DisputeStep.VOTING); + saga.currentStep = DisputeStep.PAYOUT; + + await this.webhookService.dispatch(SAGA_EVENTS.VERDICT_REACHED, { sagaId, verdict }); + this.logger.log(`Saga ${sagaId}: verdict reached — ${verdict}`); + } + + return saga; + } catch (error) { + await this.compensateVoting(saga, dto.jurorAddress, error); + throw error; + } + } + + /** Simple majority vote tally */ + private computeVerdict(votes: JurorVote[]): DisputeVerdict { + const tally = { depositor: 0, beneficiary: 0, split: 0 }; + for (const v of votes) tally[v.vote]++; + + if (tally.depositor > tally.beneficiary && tally.depositor > tally.split) { + return DisputeVerdict.DEPOSITOR_WINS; + } + if (tally.beneficiary > tally.depositor && tally.beneficiary > tally.split) { + return DisputeVerdict.BENEFICIARY_WINS; + } + return DisputeVerdict.SPLIT; + } + + // ─── Compensating action for Step 3 ────────────────────────────── + + private async compensateVoting( + saga: DisputeSaga, + jurorAddress: string, + error: unknown, + ): Promise { + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn(`Saga ${saga.sagaId}: compensating vote from ${jurorAddress} — ${reason}`); + this.recordStepFailed(saga, DisputeStep.VOTING, reason); + + try { + // Compensating action: remove the problematic vote + saga.votes = saga.votes?.filter(v => v.jurorAddress !== jurorAddress); + saga.verdict = undefined; + this.recordStepCompensated(saga, DisputeStep.VOTING); + } catch (compError) { + this.logger.error(`Saga ${saga.sagaId}: voting compensation failed`, compError); + } + + await this.webhookService.dispatch(SAGA_EVENTS.SAGA_COMPENSATING, { + sagaId: saga.sagaId, + step: DisputeStep.VOTING, + reason, + }); + } + + // ─── Step 4: Payout ─────────────────────────────────────────────── + + /** + * Executes the payout according to the verdict. + * Compensating action: reverse the release and flag the escrow for manual review. + */ + async executePayout(sagaId: string, dto: ExecutePayoutDto): Promise { + const saga = this.findById(sagaId); + this.assertStep(saga, DisputeStep.PAYOUT); + + if (!saga.verdict) { + throw new BadRequestException('Cannot execute payout: no verdict has been recorded'); + } + + this.recordStepStart(saga, DisputeStep.PAYOUT); + + try { + await this.applyPayout(saga, dto.splitPercentage); + + saga.payoutTxHash = `payout-tx-${sagaId}-${Date.now()}`; + this.recordStepComplete(saga, DisputeStep.PAYOUT); + + const now = new Date().toISOString(); + saga.currentStep = DisputeStep.COMPLETED; + saga.completedAt = now; + this.touch(saga); + + await this.webhookService.dispatch(SAGA_EVENTS.PAYOUT_EXECUTED, { + sagaId, + verdict: saga.verdict, + payoutTxHash: saga.payoutTxHash, + }); + await this.webhookService.dispatch(SAGA_EVENTS.SAGA_COMPLETED, { sagaId }); + + this.logger.log(`Saga ${sagaId}: completed — payout executed for ${saga.verdict}`); + return saga; + } catch (error) { + await this.compensatePayout(saga, error); + throw error; + } + } + + /** Apply the payout by releasing or marking the escrow based on the verdict */ + private async applyPayout(saga: DisputeSaga, splitPercentage?: number): Promise { + switch (saga.verdict) { + case DisputeVerdict.BENEFICIARY_WINS: + await this.escrowService.release(saga.escrowId); + 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'; + } + 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'; + } + } + break; + } + } + + // ─── Compensating action for Step 4 ────────────────────────────── + + private async compensatePayout(saga: DisputeSaga, error: unknown): Promise { + const reason = error instanceof Error ? error.message : String(error); + this.logger.warn(`Saga ${saga.sagaId}: compensating payout — ${reason}`); + this.recordStepFailed(saga, DisputeStep.PAYOUT, reason); + saga.currentStep = DisputeStep.COMPENSATING; + saga.compensationReason = reason; + + try { + // Compensating action: flag escrow for manual admin review + const escrow = await this.escrowService.findById(saga.escrowId); + if (escrow) { + escrow.status = 'disputed'; // revert to disputed so it isn't lost + (escrow as Escrow & { requiresManualReview?: boolean }).requiresManualReview = true; + } + saga.currentStep = DisputeStep.PAYOUT; // allow retry + this.recordStepCompensated(saga, DisputeStep.PAYOUT); + } catch (compError) { + this.logger.error(`Saga ${saga.sagaId}: payout compensation failed`, compError); + } + + this.markFailed(saga, reason); + await this.webhookService.dispatch(SAGA_EVENTS.SAGA_FAILED, { + sagaId: saga.sagaId, + step: DisputeStep.PAYOUT, + reason, + }); + } + + // ─── Helpers ────────────────────────────────────────────────────── + + private assertStep(saga: DisputeSaga, expected: DisputeStep): void { + if (saga.currentStep === DisputeStep.FAILED) { + throw new BadRequestException(`Saga ${saga.sagaId} has failed and cannot be advanced`); + } + if (saga.currentStep === DisputeStep.COMPLETED) { + throw new BadRequestException(`Saga ${saga.sagaId} is already completed`); + } + if (saga.currentStep !== expected) { + throw new BadRequestException( + `Saga ${saga.sagaId} is at step ${saga.currentStep}, expected ${expected}`, + ); + } + } + + private touch(saga: DisputeSaga): void { + saga.updatedAt = new Date().toISOString(); + } + + private markFailed(saga: DisputeSaga, reason: string): void { + saga.currentStep = DisputeStep.FAILED; + saga.failedAt = new Date().toISOString(); + saga.compensationReason = reason; + this.touch(saga); + } + + private recordStepStart(saga: DisputeSaga, step: DisputeStep): void { + // Remove any prior incomplete record for the same step (idempotent retry) + saga.stepHistory = saga.stepHistory.filter(r => !(r.step === step && !r.completedAt)); + saga.stepHistory.push({ step, startedAt: new Date().toISOString() }); + } + + private recordStepComplete(saga: DisputeSaga, step: DisputeStep): void { + const record = this.lastRecord(saga, step); + if (record) record.completedAt = new Date().toISOString(); + } + + private recordStepFailed(saga: DisputeSaga, step: DisputeStep, error: string): void { + const record = this.lastRecord(saga, step); + if (record) { + record.failedAt = new Date().toISOString(); + record.error = error; + } + } + + private recordStepCompensated(saga: DisputeSaga, step: DisputeStep): void { + const record = this.lastRecord(saga, step); + if (record) record.compensatedAt = new Date().toISOString(); + } + + private lastRecord(saga: DisputeSaga, step: DisputeStep): SagaStepRecord | undefined { + return [...saga.stepHistory].reverse().find(r => r.step === step); + } +} diff --git a/backend/src/dispute/dispute.dto.ts b/backend/src/dispute/dispute.dto.ts new file mode 100644 index 0000000..76ceb5f --- /dev/null +++ b/backend/src/dispute/dispute.dto.ts @@ -0,0 +1,97 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsArray, + ArrayMinSize, + ArrayMaxSize, + IsIn, + MinLength, + MaxLength, + IsOptional, + IsNumber, + Min, + Max, +} from 'class-validator'; +import { DisputeStep, DisputeVerdict, JurorVote, SagaStepRecord } from './dispute.types'; + +export class EscalateDisputeDto { + @ApiProperty({ description: 'Stellar address of the initiating party', example: 'GXXX...' }) + @IsString() + @IsNotEmpty() + initiator: string; + + @ApiProperty({ description: 'Reason for the dispute', minLength: 10, maxLength: 500 }) + @IsString() + @IsNotEmpty() + @MinLength(10) + @MaxLength(500) + reason: string; +} + +export class AssignJurorsDto { + @ApiProperty({ + description: '3–7 juror Stellar addresses', + type: [String], + minItems: 3, + maxItems: 7, + }) + @IsArray() + @ArrayMinSize(3) + @ArrayMaxSize(7) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + jurors: string[]; +} + +export class CastVoteDto { + @ApiProperty({ description: 'Stellar address of the voting juror' }) + @IsString() + @IsNotEmpty() + jurorAddress: string; + + @ApiProperty({ enum: ['depositor', 'beneficiary', 'split'] }) + @IsString() + @IsIn(['depositor', 'beneficiary', 'split']) + vote: 'depositor' | 'beneficiary' | 'split'; +} + +export class ExecutePayoutDto { + @ApiPropertyOptional({ + description: 'Depositor share percentage for a split verdict (0–100)', + example: 50, + }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + splitPercentage?: number; +} + +export class SagaStepRecordDto implements SagaStepRecord { + @ApiProperty({ enum: DisputeStep }) step: DisputeStep; + @ApiProperty() startedAt: string; + @ApiPropertyOptional() completedAt?: string; + @ApiPropertyOptional() failedAt?: string; + @ApiPropertyOptional() compensatedAt?: string; + @ApiPropertyOptional() error?: string; +} + +export class DisputeSagaResponseDto { + @ApiProperty() sagaId: string; + @ApiProperty() escrowId: string; + @ApiProperty() initiator: string; + @ApiProperty() reason: string; + @ApiProperty({ enum: DisputeStep }) currentStep: DisputeStep; + @ApiPropertyOptional() escalationTxHash?: string; + @ApiPropertyOptional({ type: [String] }) assignedJurors?: string[]; + @ApiPropertyOptional() votes?: JurorVote[]; + @ApiPropertyOptional({ enum: DisputeVerdict }) verdict?: DisputeVerdict; + @ApiPropertyOptional() payoutTxHash?: string; + @ApiProperty({ type: [SagaStepRecordDto] }) stepHistory: SagaStepRecordDto[]; + @ApiProperty() createdAt: string; + @ApiProperty() updatedAt: string; + @ApiPropertyOptional() completedAt?: string; + @ApiPropertyOptional() failedAt?: string; + @ApiPropertyOptional() compensationReason?: string; +} diff --git a/backend/src/dispute/dispute.module.ts b/backend/src/dispute/dispute.module.ts new file mode 100644 index 0000000..7d10bab --- /dev/null +++ b/backend/src/dispute/dispute.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { DisputeSagaService } from './dispute-saga.service'; +import { DisputeSagaController } from './dispute-saga.controller'; +import { EscrowModule } from '../escrow/escrow.module'; +import { WebhookModule } from '../webhook/webhook.module'; + +@Module({ + imports: [EscrowModule, WebhookModule], + controllers: [DisputeSagaController], + providers: [DisputeSagaService], + exports: [DisputeSagaService], +}) +export class DisputeModule {} diff --git a/backend/src/dispute/dispute.types.ts b/backend/src/dispute/dispute.types.ts new file mode 100644 index 0000000..6d7bcd4 --- /dev/null +++ b/backend/src/dispute/dispute.types.ts @@ -0,0 +1,50 @@ +export enum DisputeStep { + PENDING = 'PENDING', + ESCALATION = 'ESCALATION', + JUROR_ASSIGNMENT = 'JUROR_ASSIGNMENT', + VOTING = 'VOTING', + PAYOUT = 'PAYOUT', + COMPLETED = 'COMPLETED', + COMPENSATING = 'COMPENSATING', + FAILED = 'FAILED', +} + +export enum DisputeVerdict { + DEPOSITOR_WINS = 'DEPOSITOR_WINS', + BENEFICIARY_WINS = 'BENEFICIARY_WINS', + SPLIT = 'SPLIT', +} + +export interface JurorVote { + jurorAddress: string; + vote: 'depositor' | 'beneficiary' | 'split'; + castAt: string; +} + +export interface SagaStepRecord { + step: DisputeStep; + startedAt: string; + completedAt?: string; + failedAt?: string; + compensatedAt?: string; + error?: string; +} + +export interface DisputeSaga { + sagaId: string; + escrowId: string; + initiator: string; + reason: string; + currentStep: DisputeStep; + escalationTxHash?: string; + assignedJurors?: string[]; + votes?: JurorVote[]; + verdict?: DisputeVerdict; + payoutTxHash?: string; + stepHistory: SagaStepRecord[]; + createdAt: string; + updatedAt: string; + completedAt?: string; + failedAt?: string; + compensationReason?: string; +} diff --git a/backend/src/escrow/escrow.service.ts b/backend/src/escrow/escrow.service.ts index 146b482..2afd4b7 100644 --- a/backend/src/escrow/escrow.service.ts +++ b/backend/src/escrow/escrow.service.ts @@ -5,7 +5,7 @@ export interface Escrow { depositor: string; beneficiary: string; amountXLM: string; - status: 'pending' | 'active' | 'released' | 'disputed'; + status: 'pending' | 'active' | 'released' | 'disputed' | 'cancelled'; createdAt: string; disputeReason?: string; disputedAt?: string;