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
1 change: 1 addition & 0 deletions src/common/validators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './stellar-memo.pipe';
export * from './stellar-address.schema';
export * from './stellar-address.pipe';
export * from './zod-error';
export * from './stellar-asset.schema';
14 changes: 14 additions & 0 deletions src/common/validators/stellar-asset.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { z } from 'zod';

/**
* Stellar asset codes are 1–12 ASCII alphanumeric characters. The native asset
* is represented by XLM; issued assets use the same code constraints and carry
* their issuer separately in the Stellar SDK layer.
*/
export const stellarAssetCodeSchema = z
.string({ invalid_type_error: 'Asset code must be a string' })
.min(1, 'Asset code is required')
.regex(/^[a-zA-Z0-9]+$/, 'Asset code must contain only ASCII letters and digits')
.max(12, 'Asset code must be at most 12 characters');

export type StellarAssetCode = z.infer<typeof stellarAssetCodeSchema>;
84 changes: 84 additions & 0 deletions src/modules/transactions/stellar-transaction-validation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import { ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { stellarAssetCodeSchema } from '../../common/validators/stellar-asset.schema';
import { createTransactionSchema } from './transaction.dto';

const metadata: ArgumentMetadata = { type: 'body', metatype: Object };
const basePayload = {
walletId: '550e8400-e29b-41d4-a716-446655440000',
amount: '10.0000000',
recipientAddress: 'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
};

describe('Stellar transaction payload validation', () => {
it.each(['XLM', 'USDC', 'USD', 'A1', '123456789012'])(
'accepts valid Stellar asset code %s',
(asset) => {
expect(stellarAssetCodeSchema.parse(asset)).toBe(asset);
},
);

it.each([
['', 'Asset code is required'],
['ASSET-WITH-DASH', 'Asset code must contain only ASCII letters and digits'],
['ASSET_WITH_UNDERSCORE', 'Asset code must contain only ASCII letters and digits'],
['TOOLONGASSET123', 'Asset code must be at most 12 characters'],
['USD$', 'Asset code must contain only ASCII letters and digits'],
])('rejects invalid asset code %s with a useful validation message', (asset, message) => {
const result = stellarAssetCodeSchema.safeParse(asset);
expect(result.success).toBe(false);
if (!result.success) expect(result.error.issues[0].message).toBe(message);
});

it('rejects malformed G... public keys and invalid checksums', () => {
const pipe = new ZodValidationPipe(createTransactionSchema);
for (const recipientAddress of [
'G-not-a-valid-key',
'gdegsxlgankhk7qfov63xcbhbtz3yrkujv7zb7jmsjqb5cnbrll5qig5',
'GDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG4',
'CDEGSXLGANKHK7QFOV63XCBHBTZ3YRKUJV7ZB7JMSJQB5CNBRLL5QIG5',
]) {
expect(() => pipe.transform({ ...basePayload, recipientAddress }, metadata)).toThrow(
BadRequestException,
);
}
});

it('returns HTTP 400 with field-level details for invalid Stellar transfer payloads', () => {
const pipe = new ZodValidationPipe(createTransactionSchema);
try {
pipe.transform({ ...basePayload, asset: 'USD$', amount: '-1' }, metadata);
expect.fail('Expected validation to fail');
} catch (error) {
expect(error).toBeInstanceOf(BadRequestException);
const exception = error as BadRequestException;
expect(exception.getStatus()).toBe(400);
expect(exception.getResponse()).toMatchObject({
code: 'VALIDATION_ERROR',
details: expect.arrayContaining([
expect.objectContaining({ path: 'asset' }),
expect.objectContaining({ path: 'amount' }),
]),
});
}
});

it('rejects non-positive and over-precision amounts', () => {
const pipe = new ZodValidationPipe(createTransactionSchema);
for (const amount of ['0', '-0.01', '1.12345678', 'NaN', 'Infinity']) {
expect(() => pipe.transform({ ...basePayload, amount }, metadata)).toThrow(
BadRequestException,
);
}
});

it('parses a valid transfer and applies the default XLM asset', () => {
const pipe = new ZodValidationPipe(createTransactionSchema);
expect(pipe.transform(basePayload, metadata)).toMatchObject({
asset: 'XLM',
amount: '10.0000000',
recipientAddress: basePayload.recipientAddress,
});
});
});
7 changes: 4 additions & 3 deletions src/modules/transactions/transaction.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { z } from 'zod';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { stellarMemoTypeSchema } from '../../common/validators/stellar-memo.schema';
import { stellarAddressSchema } from '../../common/validators/stellar-address.schema';
import { stellarPublicKeySchema } from '../../common/validators/stellar-address.schema';
import { stellarAssetCodeSchema } from '../../common/validators/stellar-asset.schema';
import { ValidationException } from '../../common/exceptions/domain.exception';

const idempotencyKeyPattern = /^[A-Za-z0-9._~-]{1,128}$/;
Expand Down Expand Up @@ -39,9 +40,9 @@ export const createTransactionSchema = z
walletId: z.string().uuid(),
agentId: z.string().uuid().optional(),
budgetId: z.string().uuid().optional(),
asset: z.string().min(1).max(24).default('XLM'),
asset: stellarAssetCodeSchema.default('XLM'),
amount: amountString,
recipientAddress: stellarAddressSchema,
recipientAddress: stellarPublicKeySchema,
memo: z.string().max(128).optional(),
memoType: stellarMemoTypeSchema.optional(),
memoValue: z.string().optional(),
Expand Down
Loading