diff --git a/src/common/validators/index.ts b/src/common/validators/index.ts index 8d84142a..2d656f0e 100644 --- a/src/common/validators/index.ts +++ b/src/common/validators/index.ts @@ -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'; diff --git a/src/common/validators/stellar-asset.schema.ts b/src/common/validators/stellar-asset.schema.ts new file mode 100644 index 00000000..3b5a45a5 --- /dev/null +++ b/src/common/validators/stellar-asset.schema.ts @@ -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; diff --git a/src/modules/transactions/stellar-transaction-validation.spec.ts b/src/modules/transactions/stellar-transaction-validation.spec.ts new file mode 100644 index 00000000..e84e6c2b --- /dev/null +++ b/src/modules/transactions/stellar-transaction-validation.spec.ts @@ -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, + }); + }); +}); diff --git a/src/modules/transactions/transaction.dto.ts b/src/modules/transactions/transaction.dto.ts index 12555d8b..9ccd4b2d 100644 --- a/src/modules/transactions/transaction.dto.ts +++ b/src/modules/transactions/transaction.dto.ts @@ -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}$/; @@ -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(),