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
4 changes: 2 additions & 2 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { BountiesService } from './bounties.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ClaimBountyDto } from './dto/claim-bounty.dto';
import { BountyStatus } from '../common/enums';
import { IsStellarAddress } from '../common/validators/stellar-address.validator';
import { Idempotent } from '../common/idempotency/idempotent.decorator';

class FundBountyDto {
@IsString()
@IsStellarAddress()
funderAddress: string;
}

Expand Down
215 changes: 215 additions & 0 deletions src/common/validators/stellar-address.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import {
Controller,
Post,
Body,
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import {
IsStellarAddress,
isValidStellarAddress,
} from './stellar-address.validator';
import { FundEscrowDto } from '../../escrow/dto/fund-escrow.dto';
import { ReleaseEscrowDto } from '../../escrow/dto/release-escrow.dto';
import { SplitReleaseDto } from '../../escrow/dto/split-release.dto';
import { AssetType } from '../enums';

class TestAddressDto {
@IsStellarAddress()
address: string;
}

@Controller('test-stellar-address')
class TestAddressController {
@Post('validate')
validate(@Body() dto: TestAddressDto) {
return { ok: true, address: dto.address };
}

@Post('fund')
fund(@Body() dto: FundEscrowDto) {
return { ok: true, dto };
}

@Post('release')
release(@Body() dto: ReleaseEscrowDto) {
return { ok: true, dto };
}

@Post('split-release')
splitRelease(@Body() dto: SplitReleaseDto) {
return { ok: true, dto };
}
}

const VALID_STELLAR_ADDRESS =
'GAZRVG3HD4DYUK22IPELHZLMKLBUDUNILCL2OCDQPSVLRJSCCDD7OS5C';
const VALID_STELLAR_ADDRESS_2 =
'GAR2PDKGEZXQP5X2EFMOSLJXI26HATS6VZVZATOMCWKXU26UASMJTCH5';

describe('Stellar Address Validation (#60)', () => {
let app: INestApplication<App>;

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [TestAddressController],
}).compile();

app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
await app.init();
});

afterAll(async () => {
await app.close();
});

describe('isValidStellarAddress unit check', () => {
it('accepts a valid Ed25519 public key', () => {
expect(isValidStellarAddress(VALID_STELLAR_ADDRESS)).toBe(true);
expect(isValidStellarAddress(VALID_STELLAR_ADDRESS_2)).toBe(true);
});

it.each([
['empty string', ''],
['non-string', 12345],
['null', null],
['undefined', undefined],
['short string', 'GABC123'],
[
'not starting with G',
'SBCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ23',
],
[
'invalid base32 characters',
'G18901890189018901890189018901890189018901890189018901890',
],
[
'56-char checksum failure',
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
],
])('rejects invalid address format: %s', (_, val) => {
expect(isValidStellarAddress(val)).toBe(false);
});
});

describe('HTTP boundary validation via ValidationPipe', () => {
it('accepts valid Stellar address in payload', async () => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/validate')
.send({ address: VALID_STELLAR_ADDRESS });

expect(res.status).toBe(201);
expect(res.body).toEqual({ ok: true, address: VALID_STELLAR_ADDRESS });
});

it.each([
['empty string', ''],
['garbage string', 'not-an-address'],
['wrong length', 'GABC123456'],
[
'checksum-invalid 56-char',
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
],
])(
'rejects bad address (%s) with 400 Bad Request',
async (_, badAddress) => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/validate')
.send({ address: badAddress });

expect(res.status).toBe(400);
const body = res.body as { message: string[] };
expect(body.message).toEqual(
expect.arrayContaining([
expect.stringContaining('must be a valid Stellar public key'),
]),
);
},
);

it('rejects FundEscrowDto with malformed funderAddress at HTTP boundary', async () => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/fund')
.send({
amount: '100.0000000',
asset: AssetType.USDC,
funderAddress: 'invalid-funder-address',
bountyId: 'b0000000-0000-4000-8000-000000000001',
});

expect(res.status).toBe(400);
const body = res.body as { message: string[] };
expect(body.message).toEqual(
expect.arrayContaining([
expect.stringContaining(
'funderAddress must be a valid Stellar public key',
),
]),
);
});

it('accepts FundEscrowDto with valid funderAddress', async () => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/fund')
.send({
amount: '100.0000000',
asset: AssetType.USDC,
funderAddress: VALID_STELLAR_ADDRESS,
bountyId: 'b0000000-0000-4000-8000-000000000001',
});

expect(res.status).toBe(201);
const body = res.body as { ok: boolean };
expect(body.ok).toBe(true);
});

it('rejects ReleaseEscrowDto with malformed recipientAddress at HTTP boundary', async () => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/release')
.send({
recipientAddress:
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
});

expect(res.status).toBe(400);
const body = res.body as { message: string[] };
expect(body.message).toEqual(
expect.arrayContaining([
expect.stringContaining(
'recipientAddress must be a valid Stellar public key',
),
]),
);
});

it('rejects SplitReleaseDto with malformed recipientAddress in nested array', async () => {
const res = await request(app.getHttpServer())
.post('/test-stellar-address/split-release')
.send({
recipients: [
{ recipientAddress: VALID_STELLAR_ADDRESS, percentage: 50 },
{ recipientAddress: 'bad-address', percentage: 50 },
],
});

expect(res.status).toBe(400);
const body = res.body as { message: string[] };
expect(body.message).toEqual(
expect.arrayContaining([
expect.stringContaining(
'recipients.1.recipientAddress must be a valid Stellar public key',
),
]),
);
});
});
});
39 changes: 39 additions & 0 deletions src/common/validators/stellar-address.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
import { StrKey } from '@stellar/stellar-sdk';

/**
* Checks whether a given string is a valid Stellar public key (StrKey encoding,
* Ed25519 G... format with valid CRC16 checksum).
*/
export function isValidStellarAddress(value: unknown): value is string {
if (typeof value !== 'string') return false;
if (!value) return false;
return StrKey.isValidEd25519PublicKey(value);
}

/**
* Class-validator decorator requiring the decorated property to be a valid
* Stellar Ed25519 public key.
*/
export function IsStellarAddress(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isStellarAddress',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
return isValidStellarAddress(value);
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid Stellar public key (Ed25519 StrKey format starting with 'G')`;
},
},
});
};
}
5 changes: 3 additions & 2 deletions src/escrow/dto/fund-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { AssetType } from '../../common/enums';
import {
IsMoneyAmount,
IsSupportedEscrowAsset,
} from '../../common/validators/money.validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class FundEscrowDto {
@ApiProperty({ description: 'Amount to lock in the escrow contract' })
Expand All @@ -16,7 +17,7 @@ export class FundEscrowDto {
asset: AssetType;

@ApiProperty({ description: 'Stellar public key of the funding sponsor' })
@IsString()
@IsStellarAddress()
funderAddress: string;

@ApiProperty({ required: false })
Expand Down
5 changes: 3 additions & 2 deletions src/escrow/dto/release-escrow.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID } from 'class-validator';
import { IsOptional, IsUUID } from 'class-validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class ReleaseEscrowDto {
@ApiProperty({ description: 'Stellar public key of the recipient' })
@IsString()
@IsStellarAddress()
recipientAddress: string;

@ApiProperty({ required: false })
Expand Down
4 changes: 2 additions & 2 deletions src/escrow/dto/split-release.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ import {
ArrayMinSize,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
Min,
ValidateNested,
} from 'class-validator';
import { IsStellarAddress } from '../../common/validators/stellar-address.validator';

export class SplitRecipientDto {
@ApiProperty()
@IsString()
@IsStellarAddress()
recipientAddress: string;

@ApiProperty({ required: false })
Expand Down
Loading