Skip to content
Open
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
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
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
21 changes: 0 additions & 21 deletions src/sponsors/sponsors.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,25 +26,6 @@ function validatePage(limit?: number, offset?: number) {
throw new BadRequestException('offset must be >= 0');
}
return { limit, offset };
/**
* Parse optional limit/offset query params, rejecting NaN, negatives,
* non-integers, and limits above MAX_PAGE_LIMIT with a 400 (#280).
*/
function parsePagination(limit?: string, offset?: string) {
const parse = (name: string, raw: string | undefined, min: number, max: number) => {
if (raw === undefined || raw === '') return undefined;
const n = Number(raw);
if (!Number.isInteger(n) || n < min || n > max) {
throw new BadRequestException(
`${name} must be an integer between ${min} and ${max}`,
);
}
return n;
};
return {
limit: parse('limit', limit, 1, MAX_PAGE_LIMIT),
offset: parse('offset', offset, 0, Number.MAX_SAFE_INTEGER),
};
}

@ApiTags('sponsors')
Expand Down Expand Up @@ -73,7 +54,6 @@ export class SponsorsController {
) {
this.assertOwnsSponsor(user, id);
return this.sponsorsService.dashboard(id, validatePage(limit, offset));
return this.sponsorsService.dashboard(id, parsePagination(limit, offset));
}

@ApiBearerAuth()
Expand All @@ -91,6 +71,5 @@ export class SponsorsController {
id,
validatePage(limit, offset),
);
return this.sponsorsService.milestoneProgress(id, parsePagination(limit, offset));
}
}
9 changes: 9 additions & 0 deletions test/mocks/stellar-sdk.mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ class TransactionBuilder {
}
}


class StrKey {
// Tests only need accept/reject — real CRC16 stays in the SDK's prod path.
static isValidEd25519PublicKey(value) {
return typeof value === 'string' && /^G[A-Z2-7]{55}$/.test(value);
}
}

module.exports = {
StrKey,
Contract,
Address,
Keypair,
Expand Down