Skip to content
Closed
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
2 changes: 0 additions & 2 deletions src/common/entities/team-member-split.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ import {
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Index,
} from 'typeorm';
import { Team } from './team.entity';
import { User } from './user.entity';

@Index('IDX_team_member_splits_teamId', ['teamId'])
@Entity('team_member_splits')
@Index('IDX_team_member_splits_team_id', ['teamId'])
export class TeamMemberSplit {
Expand Down
7 changes: 5 additions & 2 deletions src/escrow/dto/fund-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsDateString,
IsDate,
IsOptional,
IsString,
IsUUID,
Expand Down Expand Up @@ -89,7 +89,10 @@ export class FundEscrowDto {
'ISO 8601 refund-deadline passed to escrow::fund (defaults to the configured window)',
})
@IsOptional()
@IsDateString()
// @Type(() => Date) transforms before validation, so IsDateString (which
// requires a string) would reject every real deadline. IsDate accepts the
// transformed value and still rejects NaN "Invalid Date" results.
@IsDate()
@Type(() => Date)
deadline?: Date;
}
17 changes: 16 additions & 1 deletion src/escrow/escrow-response.mapper.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { Escrow } from '../common/entities';
import { Escrow, Payment } from '../common/entities';

export type PublicEscrow = Omit<Escrow, 'metadata'>;

/** A Payment with its `escrow` relation redacted to the public shape. */
export type PublicPayment = Omit<Payment, 'escrow'> & {
escrow?: PublicEscrow;
};

/**
* Strips `metadata` before an Escrow reaches an HTTP client. `metadata`
* intentionally stores raw diagnostic detail from Soroban RPC calls —
Expand All @@ -20,3 +25,13 @@ export function toPublicEscrow(escrow: Escrow): PublicEscrow {
delete publicEscrow.metadata;
return publicEscrow as PublicEscrow;
}

/**
* #91 — `splitRelease` returned raw `Payment[]`, so a loaded `payment.escrow`
* relation carried `Escrow.metadata` (raw Soroban diagnostics) to the client.
* Strip it the same way `toPublicEscrow` does for the escrow routes.
*/
export function toPublicPayment(payment: Payment): PublicPayment {
const { escrow, ...rest } = payment;
return escrow ? { ...rest, escrow: toPublicEscrow(escrow) } : { ...rest };
}
22 changes: 11 additions & 11 deletions src/escrow/escrow.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('EscrowController', () => {
});

it('release() returns the service escrow without metadata', async () => {
const result = await controller.release('escrow-1', 'GRECIPIENT', 'user-1');
const result = await controller.release('escrow-1', { recipientAddress: 'GRECIPIENT', recipientId: 'user-1' });

expect(mockEscrowService.release).toHaveBeenCalledWith(
'escrow-1',
Expand All @@ -142,7 +142,7 @@ describe('EscrowController', () => {
{ recipientAddress: 'GB', percentage: 50 },
];

const result = await controller.splitRelease('escrow-1', recipients);
const result = await controller.splitRelease('escrow-1', { recipients });

expect(mockEscrowService.splitRelease).toHaveBeenCalledWith(
'escrow-1',
Expand Down Expand Up @@ -210,11 +210,11 @@ describe('EscrowController', () => {

it('release() is restricted to MAINTAINER and refund() to MAINTAINER/SPONSOR', () => {
expect(Reflect.getMetadata(ROLES_KEY, handlerFor('release'))).toEqual([
'MAINTAINER',
'maintainer',
]);
expect(Reflect.getMetadata(ROLES_KEY, handlerFor('refund'))).toEqual([
'MAINTAINER',
'SPONSOR',
'maintainer',
'sponsor',
]);
});

Expand All @@ -235,15 +235,15 @@ describe('EscrowController', () => {
const base = {
amount: '100.0000000',
asset: AssetType.USDC,
funderAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567abcdefghijklmn',
bountyId: '00000000-0000-0000-0000-000000000001',
funderAddress: 'GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A',
bountyId: 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11',
};

it('accepts onChainIssueId, sponsorId and deadline', async () => {
const dto = plainToInstance(FundEscrowDto, {
...base,
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: '2026-12-31T00:00:00.000Z',
});
const errors = await validate(dto);
Expand Down Expand Up @@ -289,7 +289,7 @@ describe('EscrowController', () => {
const dto = plainToInstance(FundEscrowDto, {
amount: 'not-money',
asset: AssetType.USDC,
funderAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567abcdefghijklmn',
funderAddress: 'GAHK7EEG2WWHVKDNT4CEQFZGKF2LGDSW2IVM4S5DP42RBW3K6BTODB4A',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
Expand All @@ -299,15 +299,15 @@ describe('EscrowController', () => {
const dto = plainToInstance(FundEscrowDto, {
...base,
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: '2026-12-31T00:00:00.000Z',
});
mockEscrowService.fund.mockResolvedValueOnce({ id: 'escrow-1' });
await controller.fund(dto);
expect(mockEscrowService.fund).toHaveBeenCalledWith(
expect.objectContaining({
onChainIssueId: '4242',
sponsorId: '00000000-0000-0000-0000-000000000002',
sponsorId: 'b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22',
deadline: new Date('2026-12-31T00:00:00.000Z'),
}),
);
Expand Down
12 changes: 8 additions & 4 deletions src/escrow/escrow.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { EscrowService } from './escrow.service';
import { FundEscrowDto } from './dto/fund-escrow.dto';
import { ReleaseEscrowDto } from './dto/release-escrow.dto';
import { SplitReleaseDto } from './dto/split-release.dto';
import { toPublicEscrow } from './escrow-response.mapper';
import { toPublicEscrow, toPublicPayment } from './escrow-response.mapper';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import {
ApiInternalErrorResponse,
Expand Down Expand Up @@ -47,7 +47,8 @@ export class EscrowController {

@ApiOperation({
summary: 'Release an escrow to its recipient',
description: 'Rate-limited to 1 request/second to protect against replay/DoS.',
description:
'Rate-limited to 1 request/second to protect against replay/DoS.',
})
@ApiBearerAuth()
@ApiStandardErrorResponses()
Expand Down Expand Up @@ -82,12 +83,15 @@ export class EscrowController {
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: SplitReleaseDto,
) {
return this.escrowService.splitRelease(id, dto.recipients);
return this.escrowService
.splitRelease(id, dto.recipients)
.then((payments) => payments.map(toPublicPayment));
}

@ApiOperation({
summary: 'Refund an escrow to its original funder',
description: 'Rate-limited to 1 request/second to protect against replay/DoS.',
description:
'Rate-limited to 1 request/second to protect against replay/DoS.',
})
@ApiBearerAuth()
@ApiStandardErrorResponses()
Expand Down
10 changes: 2 additions & 8 deletions src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,20 +584,14 @@ export class EscrowService {
recipients: Array<[string, number]>,
manager?: EntityManager,
): Promise<ContractInvocationResult> {
return this.invokeOnLockedEscrow(escrow, operation, () =>
this.soroban.invoke(
'release',
// `release(issue_id: u64, recipients)` — u64-typed on-chain (#301).
[u64(this.onChainKeyFor(escrow)), recipients],
this.contractOpts(escrow),
),
return this.invokeOnLockedEscrow(
escrow,
operation,
() =>
this.soroban.invoke(
'release',
[this.onChainKeyFor(escrow), recipients],
// `release(issue_id: u64, recipients)` — u64-typed on-chain (#301).
[u64(this.onChainKeyFor(escrow)), recipients],
this.contractOpts(escrow),
),
manager,
Expand Down
30 changes: 16 additions & 14 deletions src/github/github-webhooks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import * as sigUtil from './webhook-signature.util';

describe('GithubWebhooksService', () => {
let service: GithubWebhooksService;
let webhookEventRepo: { create: jest.Mock; save: jest.Mock };
let webhookEventRepo: {
create: jest.Mock;
save: jest.Mock;
Expand Down Expand Up @@ -40,6 +39,7 @@ describe('GithubWebhooksService', () => {
findOne: jest.fn().mockResolvedValue(null),
};
bountyRepo = { findOne: jest.fn() };
issueRepo = { findOne: jest.fn().mockResolvedValue(null) };
bountiesService = {
markInReview: jest.fn().mockResolvedValue(undefined),
markMergedAndRelease: jest.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -264,11 +264,11 @@ describe('GithubWebhooksService', () => {
byNumber: Record<number, { bountyId: string; status: string }>,
) {
syncService.findIssueByRepoAndNumber.mockImplementation(
({ where }: { where: { number: number } }) => {
const entry = byNumber[where.number];
(_repoId: string, number: number) => {
const entry = byNumber[number];
return Promise.resolve(
entry
? { id: `issue-${where.number}`, bounty: { id: entry.bountyId } }
? { id: `issue-${number}`, bounty: { id: entry.bountyId } }
: null,
);
},
Expand Down Expand Up @@ -637,9 +637,8 @@ describe('GithubWebhooksService', () => {
describe('comma-separated closing references (#309)', () => {
/** Collects every issue number the merged-PR path ends up releasing. */
function releasedNumbers(): number[] {
return issueRepo.findOne.mock.calls.map(
(call: unknown[]) =>
(call[0] as { where: { number: number } }).where.number,
return syncService.findIssueByRepoAndNumber.mock.calls.map(
(call: unknown[]) => call[1] as number,
);
}

Expand Down Expand Up @@ -732,11 +731,11 @@ describe('GithubWebhooksService', () => {
});

it('marks a comma-separated bounty in review when the PR is opened', async () => {
issueRepo.findOne.mockImplementation(
({ where }: { where: { number: number } }) =>
syncService.findIssueByRepoAndNumber.mockImplementation(
(_repoId: string, number: number) =>
Promise.resolve({
id: `issue-${where.number}`,
bounty: { id: `bounty-${where.number}` },
id: `issue-${number}`,
bounty: { id: `bounty-${number}` },
}),
);
bountyRepo.findOne.mockImplementation(
Expand Down Expand Up @@ -782,10 +781,10 @@ describe('GithubWebhooksService', () => {

describe('pull_request edited events (#310)', () => {
it('moves a bounty to in_review when a closing keyword is added after opening', async () => {
issueRepo.findOne.mockImplementation(
({ where }: { where: { number: number } }) =>
syncService.findIssueByRepoAndNumber.mockImplementation(
(_repoId: string, number: number) =>
Promise.resolve({
id: `issue-${where.number}`,
id: `issue-${number}`,
bounty: { id: 'bounty-42' },
}),
);
Expand Down Expand Up @@ -869,6 +868,9 @@ describe('GithubWebhooksService', () => {

expect(bountiesService.markInReview).not.toHaveBeenCalled();
expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled();
});
});

// #308: a redelivered X-GitHub-Delivery used to hit the unique constraint
// on webhook_events.deliveryId and escape handleEvent as a 500, so every
// redelivery of that event failed forever.
Expand Down
32 changes: 26 additions & 6 deletions src/milestones/milestones.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { Request } from 'express';
import { Test, TestingModule } from '@nestjs/testing';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { MilestonesController } from './milestones.controller';
import { MilestonesService } from './milestones.service';
import { CreateMilestoneDto } from './dto/create-milestone.dto';
import { AssetType, MilestoneStatus } from '../common/enums';
import { getRepositoryToken } from '@nestjs/typeorm';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { IdempotencyKey } from '../common/entities/idempotency-key.entity';

describe('MilestonesController', () => {
let controller: MilestonesController;
Expand All @@ -33,7 +36,13 @@ describe('MilestonesController', () => {

const module: TestingModule = await Test.createTestingModule({
controllers: [MilestonesController],
providers: [{ provide: MilestonesService, useValue: milestonesService }],
providers: [
{ provide: MilestonesService, useValue: milestonesService },
{
provide: getRepositoryToken(IdempotencyKey),
useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() },
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
Expand All @@ -53,9 +62,14 @@ describe('MilestonesController', () => {
asset: AssetType.USDC,
};

await controller.create(dto);
await controller.create(dto, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(milestonesService.create).toHaveBeenCalledWith(dto);
expect(milestonesService.create).toHaveBeenCalledWith(
dto,
'sponsor-1',
);
});
});

Expand All @@ -77,9 +91,15 @@ describe('MilestonesController', () => {

describe('fund', () => {
it('calls milestonesService.fund with id and funderAddress', async () => {
await controller.fund('m1', { funderAddress: 'GFUNDER' });

expect(milestonesService.fund).toHaveBeenCalledWith('m1', 'GFUNDER');
await controller.fund('m1', { funderAddress: 'GFUNDER' }, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(milestonesService.fund).toHaveBeenCalledWith(
'm1',
'GFUNDER',
'sponsor-1',
);
});
});

Expand Down
8 changes: 5 additions & 3 deletions src/milestones/milestones.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe('MilestonesService', () => {
sponsorId: 'sponsor-1',
});

const milestone = await service.fund('m1', 'GFUNDER');
const milestone = await service.fund('m1', 'GFUNDER', 'sponsor-1');

expect(milestone.status).toBe(MilestoneStatus.FUNDED);
expect(milestone.escrowId).toBe('escrow-1');
Expand All @@ -90,7 +90,7 @@ describe('MilestonesService', () => {
sponsorId: 'sponsor-1',
});

await service.fund('m1', 'GFUNDER');
await service.fund('m1', 'GFUNDER', 'sponsor-1');

expect(escrowService.fund).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -263,6 +263,7 @@ describe('MilestonesService', () => {
'250.0000000',
'RECIPIENT_ADDR',
'user-1',
expect.anything(),
);
expect(payment.id).toBe('payment-1');
expect(dataSource.transaction).toHaveBeenCalled();
Expand Down Expand Up @@ -341,7 +342,7 @@ describe('MilestonesService', () => {

await expect(
service.resolveIssue('m1', 'i1', 'RECIPIENT_ADDR'),
).rejects.toThrow('Milestone m1 is not accepting distributions');
).rejects.toThrow('Cannot transition milestone');
});

it('rejects when the issue does not belong to the milestone (#114)', async () => {
Expand Down Expand Up @@ -406,6 +407,7 @@ describe('MilestonesService', () => {
'333.3333333',
'RECIPIENT_ADDR',
undefined,
expect.anything(),
);
});
});
Expand Down
Loading