init
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import type { IsBase64Options } from '../../types/validation/IsBase64Options';
|
||||
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
@ValidatorConstraint({ name: 'isBase64' })
|
||||
class IsBase64Constraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!BASE64_PATTERN.test(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
|
||||
if (decoded.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [{ byteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return decoded.length === byteLength;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ byteLength }] = args.constraints as [IsBase64Options];
|
||||
|
||||
if (byteLength !== undefined) {
|
||||
return `$property must be a base64-encoded ${byteLength}-byte value`;
|
||||
}
|
||||
|
||||
return '$property must be a valid base64 string';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { validateSync } from 'class-validator';
|
||||
import { IsMoneroConfirmationTiers } from './isMoneroConfirmationTiers';
|
||||
|
||||
class TestDto {
|
||||
@IsMoneroConfirmationTiers()
|
||||
MONERO_CONFIRMATION_TIERS: string;
|
||||
}
|
||||
|
||||
const validateTiers = (value: string) => {
|
||||
const dto = Object.assign(new TestDto(), { MONERO_CONFIRMATION_TIERS: value });
|
||||
|
||||
return validateSync(dto);
|
||||
};
|
||||
|
||||
const validTiers =
|
||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
|
||||
describe('IsMoneroConfirmationTiers', () => {
|
||||
it('accepts valid default tiers', () => {
|
||||
expect(validateTiers(validTiers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts numeric-only tiers without tx-detected (0)', () => {
|
||||
const tiers =
|
||||
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(validateTiers('').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects invalid JSON', () => {
|
||||
expect(validateTiers('not-json').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects empty array', () => {
|
||||
expect(validateTiers('[]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects tx-detected (0) more than once', () => {
|
||||
const tiers =
|
||||
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects tx-detected (0) on catch-all tier', () => {
|
||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects legacy tx-detected string', () => {
|
||||
const tiers =
|
||||
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects missing upToTotalFiat on non-final tier', () => {
|
||||
const tiers = '[{"minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects non-positive upToTotalFiat on non-final tier', () => {
|
||||
const tiers = '[{"upToTotalFiat":"0","minConfirmations":1},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects negative minConfirmations', () => {
|
||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":-1},{"minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects catch-all tier with upToTotalFiat', () => {
|
||||
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"999","minConfirmations":10}]';
|
||||
|
||||
expect(validateTiers(tiers).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
|
||||
import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier';
|
||||
|
||||
const isPositiveDecimalString = (value: string): boolean => {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const amount = Number(trimmed);
|
||||
|
||||
return Number.isFinite(amount) && amount > 0;
|
||||
};
|
||||
|
||||
const isMinConfirmations = (value: unknown): boolean =>
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
||||
|
||||
const isMoneroConfirmationTier = (value: unknown): value is MoneroConfirmationTier => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tier = value as MoneroConfirmationTier;
|
||||
|
||||
if (!isMinConfirmations(tier.minConfirmations)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tier.upToTotalFiat === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return typeof tier.upToTotalFiat === 'string' && isPositiveDecimalString(tier.upToTotalFiat);
|
||||
};
|
||||
|
||||
const isValidMoneroConfirmationTiersJson = (raw: string): boolean => {
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isMoneroConfirmationTier)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tiers = parsed;
|
||||
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
|
||||
|
||||
if (txDetectedTierCount > 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lastTier = tiers[tiers.length - 1];
|
||||
|
||||
if (lastTier.upToTotalFiat !== undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lastTier.minConfirmations === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < tiers.length - 1; index++) {
|
||||
const tier = tiers[index];
|
||||
|
||||
if (!tier.upToTotalFiat?.trim() || !isPositiveDecimalString(tier.upToTotalFiat)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: 'isMoneroConfirmationTiers' })
|
||||
class IsMoneroConfirmationTiersConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
if (typeof value !== 'string' || !value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isValidMoneroConfirmationTiersJson(value);
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return '$property must be a non-empty JSON array of Monero confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsMoneroConfirmationTiers = () => Validate(IsMoneroConfirmationTiersConstraint);
|
||||
@@ -0,0 +1,184 @@
|
||||
import { validateSync } from 'class-validator';
|
||||
import { MoneroNetwork } from '../../types/MoneroNetwork';
|
||||
import { IsMoneroStandardAddress } from './isMoneroStandardAddress';
|
||||
|
||||
class TestDto {
|
||||
@IsMoneroStandardAddress()
|
||||
destinationAddress: string;
|
||||
}
|
||||
|
||||
const pad = (prefix: string) => `${prefix}${'A'.repeat(95 - prefix.length)}`;
|
||||
|
||||
const MAINNET_STANDARD_ADDRESS =
|
||||
'4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge';
|
||||
|
||||
const validateAddress = (value: string) => {
|
||||
const dto = Object.assign(new TestDto(), { destinationAddress: value });
|
||||
|
||||
return validateSync(dto);
|
||||
};
|
||||
|
||||
const accepts = (value: string) => {
|
||||
expect(validateAddress(value)).toHaveLength(0);
|
||||
};
|
||||
|
||||
const rejects = (value: string) => {
|
||||
expect(validateAddress(value).length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
describe('IsMoneroStandardAddress', () => {
|
||||
const originalNetwork = process.env.MONERO_NETWORK;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalNetwork === undefined) {
|
||||
delete process.env.MONERO_NETWORK;
|
||||
} else {
|
||||
process.env.MONERO_NETWORK = originalNetwork;
|
||||
}
|
||||
});
|
||||
|
||||
describe('mainnet', () => {
|
||||
beforeEach(() => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Mainnet;
|
||||
});
|
||||
|
||||
it('accepts a real mainnet standard address', () => {
|
||||
accepts(MAINNET_STANDARD_ADDRESS);
|
||||
});
|
||||
|
||||
it('accepts mainnet standard addresses', () => {
|
||||
accepts(pad('41'));
|
||||
accepts(pad('49'));
|
||||
accepts(pad('4A'));
|
||||
accepts(pad('4B'));
|
||||
});
|
||||
|
||||
it('accepts mainnet subaddresses', () => {
|
||||
accepts(pad('82'));
|
||||
accepts(pad('89'));
|
||||
accepts(pad('8A'));
|
||||
accepts(pad('8B'));
|
||||
accepts(pad('8C'));
|
||||
});
|
||||
|
||||
it('rejects stagenet addresses', () => {
|
||||
rejects(pad('51'));
|
||||
rejects(pad('72'));
|
||||
});
|
||||
|
||||
it('rejects testnet addresses', () => {
|
||||
rejects(pad('91'));
|
||||
rejects(pad('BY'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stagenet', () => {
|
||||
beforeEach(() => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Stagenet;
|
||||
});
|
||||
|
||||
it('accepts stagenet standard addresses', () => {
|
||||
accepts(pad('51'));
|
||||
accepts(pad('59'));
|
||||
accepts(pad('5A'));
|
||||
accepts(pad('5B'));
|
||||
});
|
||||
|
||||
it('accepts stagenet subaddresses', () => {
|
||||
accepts(pad('72'));
|
||||
accepts(pad('79'));
|
||||
accepts(pad('7A'));
|
||||
accepts(pad('7B'));
|
||||
});
|
||||
|
||||
it('rejects mainnet addresses', () => {
|
||||
rejects(MAINNET_STANDARD_ADDRESS);
|
||||
rejects(pad('4A'));
|
||||
rejects(pad('82'));
|
||||
});
|
||||
|
||||
it('rejects testnet addresses', () => {
|
||||
rejects(pad('91'));
|
||||
rejects(pad('BY'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('shared validation rules', () => {
|
||||
beforeEach(() => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Mainnet;
|
||||
});
|
||||
|
||||
it('accepts lowercase base58 characters in the body', () => {
|
||||
accepts('4AdUndXHHZ6cfufTMvppY6JwXNouMBzSkbLYfpAV5Usx3skxNgYeYTRj5UzqtReoS44qo9mtmXCqY45DJ852K5Jv2684Rge');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace on mainnet', () => {
|
||||
accepts(` ${pad('4A')} `);
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace on stagenet', () => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Stagenet;
|
||||
|
||||
accepts(`\n${pad('5B')}\t`);
|
||||
});
|
||||
|
||||
it.each(['0', '1', '2', '3', '6', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G'])(
|
||||
'rejects addresses starting with %s',
|
||||
prefix => {
|
||||
rejects(pad(`${prefix}A`));
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['0', 'C'])('rejects mainnet standard addresses with second character %s', second => {
|
||||
rejects(pad(`4${second}`));
|
||||
});
|
||||
|
||||
it.each(['0', '1'])('rejects mainnet subaddresses with second character %s', second => {
|
||||
rejects(pad(`8${second}`));
|
||||
});
|
||||
|
||||
it.each(['0', 'C'])('rejects stagenet standard addresses with second character %s', second => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Stagenet;
|
||||
|
||||
rejects(pad(`5${second}`));
|
||||
});
|
||||
|
||||
it.each(['0', '1', 'C'])('rejects stagenet subaddresses with second character %s', second => {
|
||||
process.env.MONERO_NETWORK = MoneroNetwork.Stagenet;
|
||||
|
||||
rejects(pad(`7${second}`));
|
||||
});
|
||||
|
||||
it('rejects empty input', () => {
|
||||
rejects('');
|
||||
});
|
||||
|
||||
it('rejects whitespace-only input', () => {
|
||||
rejects(' ');
|
||||
});
|
||||
|
||||
it('rejects addresses that are too short', () => {
|
||||
rejects(pad('4A').slice(0, 94));
|
||||
});
|
||||
|
||||
it('rejects addresses that are too long', () => {
|
||||
rejects(`${pad('4A')}A`);
|
||||
});
|
||||
|
||||
it('rejects integrated addresses (106 chars, also start with 4 on mainnet)', () => {
|
||||
rejects(`${MAINNET_STANDARD_ADDRESS}${'A'.repeat(11)}`);
|
||||
});
|
||||
|
||||
it.each(['0', 'O', 'I', 'l'])('rejects addresses containing %s', invalidChar => {
|
||||
const address = `${pad('4A').slice(0, 10)}${invalidChar}${pad('4A').slice(11)}`;
|
||||
|
||||
rejects(address);
|
||||
});
|
||||
|
||||
it('returns a network-specific error message', () => {
|
||||
const [error] = validateAddress('invalid');
|
||||
|
||||
expect(error.constraints?.isMoneroStandardAddress).toBe('Enter a valid mainnet Monero address.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
|
||||
import { getMoneroWalletConfig } from '../../config';
|
||||
import { MoneroNetwork } from '../../types/MoneroNetwork';
|
||||
|
||||
// Standard + subaddress, 95 chars. Excludes integrated (106 chars).
|
||||
// Prefix bytes: monero-project/monero src/cryptonote_config.h
|
||||
// Regex shape: https://gist.github.com/masflam/84477ca88842e245dc7a4cc61ce299e3
|
||||
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
|
||||
|
||||
const NETWORK_ADDRESS_PATTERNS: Record<MoneroNetwork, RegExp> = {
|
||||
[MoneroNetwork.Mainnet]: new RegExp(`^(?:4[1-9AB]|8[2-9ABC])${BASE58}{93}$`),
|
||||
[MoneroNetwork.Stagenet]: new RegExp(`^(?:5[1-9AB]|7[2-9AB])${BASE58}{93}$`)
|
||||
};
|
||||
|
||||
@ValidatorConstraint({ name: 'isMoneroStandardAddress' })
|
||||
class IsMoneroStandardAddressConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { network } = getMoneroWalletConfig();
|
||||
|
||||
return NETWORK_ADDRESS_PATTERNS[network].test(value.trim());
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
const { network } = getMoneroWalletConfig();
|
||||
|
||||
return `Enter a valid ${network} Monero address.`;
|
||||
}
|
||||
}
|
||||
|
||||
export const IsMoneroStandardAddress = () => Validate(IsMoneroStandardAddressConstraint);
|
||||
@@ -0,0 +1,92 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
type ValidationArguments,
|
||||
type ValidationOptions,
|
||||
type ValidatorConstraintInterface
|
||||
} from 'class-validator';
|
||||
import type { NullOrIntOptions } from '../../types/validation/NullOrIntOptions';
|
||||
import type { NullOrNumberOptions } from '../../types/validation/NullOrNumberOptions';
|
||||
|
||||
@ValidatorConstraint({ name: 'nullOrDate' })
|
||||
class NullOrDateConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown): boolean {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return value instanceof Date && !Number.isNaN(value.getTime());
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return '$property must be null or a valid date';
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'nullOrInt' })
|
||||
class NullOrIntConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [{ min }] = args.constraints as [NullOrIntOptions];
|
||||
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return min === undefined || value >= min;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ min }] = args.constraints as [NullOrIntOptions];
|
||||
|
||||
if (min !== undefined) {
|
||||
return `$property must be null or an integer >= ${min}`;
|
||||
}
|
||||
|
||||
return '$property must be null or an integer';
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'nullOrNumber' })
|
||||
class NullOrNumberConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments): boolean {
|
||||
if (value === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [{ min }] = args.constraints as [NullOrNumberOptions];
|
||||
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return min === undefined || value >= min;
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
const [{ min }] = args.constraints as [NullOrNumberOptions];
|
||||
|
||||
if (min !== undefined) {
|
||||
return `$property must be null or a number >= ${min}`;
|
||||
}
|
||||
|
||||
return '$property must be null or a number';
|
||||
}
|
||||
}
|
||||
|
||||
export const NullOrDate = (validationOptions?: ValidationOptions) =>
|
||||
applyDecorators(
|
||||
Type(() => Date),
|
||||
Validate(NullOrDateConstraint, validationOptions)
|
||||
);
|
||||
|
||||
export const NullOrInt = (options?: NullOrIntOptions, validationOptions?: ValidationOptions) =>
|
||||
applyDecorators(Validate(NullOrIntConstraint, [options ?? {}], validationOptions));
|
||||
|
||||
export const NullOrNumber = (options?: NullOrNumberOptions, validationOptions?: ValidationOptions) =>
|
||||
applyDecorators(Validate(NullOrNumberConstraint, [options ?? {}], validationOptions));
|
||||
Reference in New Issue
Block a user