This commit is contained in:
2026-08-28 17:31:02 +02:00
commit 2b30e8bd39
694 changed files with 49243 additions and 0 deletions
@@ -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));