Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 15 additions & 20 deletions backend/src/__snapshots__/openapi.snapshot.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1974,7 +1974,7 @@ exports[`OpenAPI document matches the checked-in snapshot 1`] = `
},
"/escrows/{id}/dispute": {
"post": {
"description": "Raises a dispute for an escrow. Triggers webhook events and Discord notifications to alert jurors.",
"description": "Raises a dispute for an active escrow as the authenticated depositor or beneficiary. Starts the dispute saga, which freezes the escrow and notifies jurors.",
"operationId": "EscrowController_raiseDispute",
"parameters": [
{
Expand Down Expand Up @@ -2011,35 +2011,30 @@ exports[`OpenAPI document matches the checked-in snapshot 1`] = `
"content": {
"application/json": {
"schema": {
"properties": {
"disputeReason": {
"type": "string",
},
"disputedAt": {
"format": "date-time",
"type": "string",
},
"id": {
"type": "string",
},
"status": {
"example": "disputed",
"type": "string",
},
},
"type": "object",
"$ref": "#/components/schemas/DisputeSagaResponseDto",
},
},
},
"description": "Dispute raised successfully. Discord notification sent if configured.",
"description": "Dispute saga started successfully.",
},
"400": {
"description": "Escrow already disputed or released",
"description": "Escrow is not active",
},
"401": {
"description": "Authentication required",
},
"403": {
"description": "Only the depositor or beneficiary can raise a dispute",
},
"404": {
"description": "Escrow not found",
},
},
"security": [
{
"JWT-auth": [],
},
],
"summary": "Raise a dispute",
"tags": [
"Escrow",
Expand Down
30 changes: 29 additions & 1 deletion backend/src/dispute/dispute-saga.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import {
NotFoundException,
BadRequestException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { DisputeSagaService } from './dispute-saga.service';
import { DisputeStep, DisputeVerdict } from './dispute.types';
import { EscrowService } from '../escrow/escrow.service';
Expand Down Expand Up @@ -130,6 +135,7 @@ describe('DisputeSagaService', () => {
const firstSaga = await service.escalate('esc-001', ESCALATE_DTO);
// Simulate completion
firstSaga.currentStep = DisputeStep.COMPLETED;
escrow.status = 'active';

const secondSaga = await service.escalate('esc-001', ESCALATE_DTO);
expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId);
Expand All @@ -144,6 +150,7 @@ describe('DisputeSagaService', () => {
const firstSaga = await service.escalate('esc-001', ESCALATE_DTO);
// Simulate failure
firstSaga.currentStep = DisputeStep.FAILED;
escrow.status = 'active';

const secondSaga = await service.escalate('esc-001', ESCALATE_DTO);
expect(secondSaga.sagaId).not.toBe(firstSaga.sagaId);
Expand Down Expand Up @@ -186,6 +193,27 @@ describe('DisputeSagaService', () => {
await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow(BadRequestException);
});

it.each(['pending', 'disputed', 'cancelled'] as const)(
'rejects API escalation when escrow status is %s',
async status => {
escrowService.findById.mockResolvedValueOnce(makeEscrow({ status }));
await expect(service.escalate('esc-001', ESCALATE_DTO)).rejects.toThrow(
BadRequestException,
);
expect(escrowService.raiseDispute).not.toHaveBeenCalled();
},
);

it('rejects an initiator who is not a party to the escrow', async () => {
await expect(
service.escalate('esc-001', {
...ESCALATE_DTO,
initiator: 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
}),
).rejects.toThrow(ForbiddenException);
expect(escrowService.raiseDispute).not.toHaveBeenCalled();
});

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
Expand Down
3 changes: 3 additions & 0 deletions backend/src/dispute/dispute-saga.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ export class DisputeSagaService implements OnModuleInit {
if (escrow.status === 'released') {
throw new BadRequestException('Cannot dispute a released escrow');
}
if (origin === 'api' && escrow.status !== 'active') {
throw new BadRequestException('Only active escrows can start a dispute');
}

// Verify that initiator is either depositor or beneficiary, unless this is a
// chain-originated dispute with no reliable initiator (#463): the sentinel
Expand Down
10 changes: 8 additions & 2 deletions backend/src/dispute/dispute.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { DisputeSagaService } from './dispute-saga.service';
import { DisputeSagaController } from './dispute-saga.controller';
import { EscrowModule } from '../escrow/escrow.module';
Expand All @@ -8,7 +8,13 @@ import { NotificationModule } from '../notification/notification.module';
import { MonitoringModule } from '../monitoring/monitoring.module';

@Module({
imports: [EscrowModule, WebhookModule, ReputationModule, NotificationModule, MonitoringModule],
imports: [
forwardRef(() => EscrowModule),
WebhookModule,
ReputationModule,
NotificationModule,
MonitoringModule,
],
controllers: [DisputeSagaController],
providers: [DisputeSagaService],
exports: [DisputeSagaService],
Expand Down
86 changes: 55 additions & 31 deletions backend/src/escrow/escrow.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { EscrowController } from './escrow.controller';
import { EscrowService, Escrow } from './escrow.service';
import { EscrowReleaseTransactionBuilderService } from '../escrow-write/escrow-release-transaction-builder.service';
import { DisputeSagaService } from '../dispute/dispute-saga.service';
import { JwtAuthGuard } from '../auth/auth.guard';

// ─── Fixtures ─────────────────────────────────────────────────────────────────

const DEPOSITOR = 'GDEPOSITORAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const BENEFICIARY = 'GBENEFICIARYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const OTHER_WALLET = 'GOTHERWALLETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const AMOUNT = '100';

function makeEscrow(overrides: Partial<Escrow> = {}): Escrow {
Expand Down Expand Up @@ -40,6 +43,9 @@ function buildMocks() {
}),
};

const disputeSagaService = {
escalate: jest.fn().mockResolvedValue({ sagaId: 'saga-001', escrowId: escrow.id }),
};

const txBuilderService = {
buildRelease: jest.fn().mockResolvedValue({
Expand All @@ -54,6 +60,7 @@ function buildMocks() {
return {
escrow,
escrowService,
disputeSagaService,
txBuilderService,
};
}
Expand All @@ -76,6 +83,7 @@ describe('EscrowController', () => {
provide: EscrowReleaseTransactionBuilderService,
useValue: mocks.txBuilderService,
},
{ provide: DisputeSagaService, useValue: mocks.disputeSagaService },
],
}).compile();

Expand Down Expand Up @@ -175,58 +183,74 @@ describe('EscrowController', () => {
// ─── POST /escrows/:id/dispute ────────────────────────────────────────────

describe('raiseDispute()', () => {
it('calls EscrowService.raiseDispute(), dispatches webhook, sends Discord notification, and returns the escrow', async () => {
const disputed = makeEscrow({
status: 'disputed',
disputeReason: 'Work not delivered',
disputedAt: new Date().toISOString(),
});
mocks.escrowService.raiseDispute.mockResolvedValue(disputed);
it('requires JWT authentication', () => {
const guards = Reflect.getMetadata('__guards__', EscrowController.prototype.raiseDispute);
expect(guards).toContain(JwtAuthGuard);
});

const result = await controller.raiseDispute('esc-001', { reason: 'Work not delivered' });
it('delegates to the saga with the authenticated wallet, ignoring a caller-supplied initiator', async () => {
const saga = { sagaId: 'saga-001', escrowId: 'esc-001' };
mocks.disputeSagaService.escalate.mockResolvedValue(saga);

expect(mocks.escrowService.raiseDispute).toHaveBeenCalledWith(
const result = await controller.raiseDispute(
'esc-001',
'Work not delivered',
{ reason: 'Work not delivered', initiator: OTHER_WALLET } as any,
{ user: { address: DEPOSITOR, sub: DEPOSITOR } },
);

expect(result).toEqual(disputed);
expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', {
initiator: DEPOSITOR,
reason: 'Work not delivered',
});
expect(mocks.escrowService.raiseDispute).not.toHaveBeenCalled();
expect(result).toEqual(saga);
});

it('works when no reason is provided in the dto', async () => {
mocks.escrowService.raiseDispute.mockResolvedValue(makeEscrow({ status: 'disputed' }));

await controller.raiseDispute('esc-001', {});
it('supplies a default reason when none is provided', async () => {
await controller.raiseDispute('esc-001', {}, {
user: { address: DEPOSITOR, sub: DEPOSITOR },
});

expect(mocks.escrowService.raiseDispute).toHaveBeenCalledWith('esc-001', undefined);
expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', {
initiator: DEPOSITOR,
reason: 'No reason provided',
});
});

it('propagates BadRequestException when escrow is already released', async () => {
const { BadRequestException } = jest.requireActual('@nestjs/common');
mocks.escrowService.raiseDispute.mockRejectedValue(
new BadRequestException('Cannot dispute a released escrow'),
it('propagates ForbiddenException for a caller who is not an escrow party', async () => {
mocks.disputeSagaService.escalate.mockRejectedValue(
new ForbiddenException('Only the depositor or beneficiary can escalate a dispute'),
);

await expect(controller.raiseDispute('esc-001', { reason: 'too late' })).rejects.toThrow(
BadRequestException,
);
await expect(
controller.raiseDispute('esc-001', { reason: 'wrong party' }, {
user: { address: OTHER_WALLET, sub: OTHER_WALLET },
}),
).rejects.toThrow(ForbiddenException);
expect(mocks.disputeSagaService.escalate).toHaveBeenCalledWith('esc-001', {
initiator: OTHER_WALLET,
reason: 'wrong party',
});
});

it('propagates BadRequestException when escrow is already disputed', async () => {
const { BadRequestException } = jest.requireActual('@nestjs/common');
mocks.escrowService.raiseDispute.mockRejectedValue(
new BadRequestException('Escrow is already disputed'),
it('propagates BadRequestException when the escrow cannot start a dispute', async () => {
mocks.disputeSagaService.escalate.mockRejectedValue(
new BadRequestException('Only active escrows can start a dispute'),
);

await expect(controller.raiseDispute('esc-001', { reason: 'dupe' })).rejects.toThrow(
await expect(controller.raiseDispute('esc-001', { reason: 'not active' }, {
user: { address: DEPOSITOR, sub: DEPOSITOR },
})).rejects.toThrow(
BadRequestException,
);
});

it('propagates exception when service throws', async () => {
mocks.escrowService.raiseDispute.mockRejectedValue(new Error('unexpected'));
mocks.disputeSagaService.escalate.mockRejectedValue(new Error('unexpected'));

await expect(controller.raiseDispute('esc-001', {})).rejects.toThrow();
await expect(controller.raiseDispute('esc-001', {}, {
user: { address: DEPOSITOR, sub: DEPOSITOR },
})).rejects.toThrow();
});
});

Expand Down
46 changes: 30 additions & 16 deletions backend/src/escrow/escrow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import {
Body,
Param,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ZodError } from 'zod';
import {
ApiBody,
ApiBearerAuth,
ApiHeader,
ApiOperation,
ApiParam,
Expand All @@ -21,6 +24,9 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { EscrowService } from './escrow.service';
import { DisputeSagaService } from '../dispute/dispute-saga.service';
import { DisputeSagaResponseDto, EscalateDisputeDto } from '../dispute/dispute.dto';
import { JwtAuthGuard } from '../auth/auth.guard';
import { ReputationService } from '../reputation/reputation.service';
import { EscrowReleaseTransactionBuilderService } from '../escrow-write/escrow-release-transaction-builder.service';
import { BuildReleaseTransactionQueryDto } from '../escrow-write/escrow-write.dto';
Expand All @@ -36,12 +42,17 @@ import {
// Reference to ensure ReleaseEscrowSchema is considered used (dead-code check requires import)
void ReleaseEscrowSchema;

interface AuthenticatedRequest {
user: { address: string; sub: string };
}

@ApiTags('Escrow')
@Controller('escrows')
export class EscrowController {
constructor(
private readonly escrowService: EscrowService,
private readonly escrowReleaseTransactionBuilderService: EscrowReleaseTransactionBuilderService,
private readonly disputeSagaService: DisputeSagaService,
) {}

@Post()
Expand Down Expand Up @@ -312,11 +323,14 @@ export class EscrowController {
}

@Post(':id/dispute')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Raise a dispute',
description:
'Raises a dispute for an escrow. Triggers webhook events and Discord notifications to alert jurors.',
'Raises a dispute for an active escrow as the authenticated depositor or beneficiary. ' +
'Starts the dispute saga, which freezes the escrow and notifies jurors.',
})
@ApiParam({
name: 'id',
Expand All @@ -338,23 +352,23 @@ export class EscrowController {
})
@ApiResponse({
status: 200,
description: 'Dispute raised successfully. Discord notification sent if configured.',
schema: {
type: 'object',
properties: {
id: { type: 'string' },
status: { type: 'string', example: 'disputed' },
disputeReason: { type: 'string' },
disputedAt: { type: 'string', format: 'date-time' },
},
},
description: 'Dispute saga started successfully.',
type: DisputeSagaResponseDto,
})
@ApiResponse({ status: 400, description: 'Escrow already disputed or released' })
@ApiResponse({ status: 400, description: 'Escrow is not active' })
@ApiResponse({ status: 401, description: 'Authentication required' })
@ApiResponse({ status: 403, description: 'Only the depositor or beneficiary can raise a dispute' })
@ApiResponse({ status: 404, description: 'Escrow not found' })
async raiseDispute(@Param('id') id: string, @Body() dto: RaiseDisputeDto) {
async raiseDispute(
@Param('id') id: string,
@Body() dto: RaiseDisputeDto,
@Req() req: AuthenticatedRequest,
) {
const validated = RaiseDisputeSchema.parse(dto);
const escrow = await this.escrowService.raiseDispute(id, validated.reason);

return escrow;
const escalateDto: EscalateDisputeDto = {
initiator: req.user.address,
reason: validated.reason ?? 'No reason provided',
};
return this.disputeSagaService.escalate(id, escalateDto);
}
}
Loading