Розробка системи лімітів та верифікації обменника
Система лімітів — не просто цифри в базі даних. Це compliance інструмент, який балансує між usability (користувач хоче обміняти одразу) та regulatory вимогами (AML вимагає знати клієнта при сумах вище порогу). Правильна архітектура лімітів знижує KYC abandonment та залишається compliant.
Структура лімітів за рівнями верифікації
interface LimitTier {
daily: number; // USD еквівалент
monthly: number;
perTransaction: number;
fiatsAllowed: boolean;
cryptoWithdrawalLimit: number;
requiresKYC: KYCLevel;
}
const LIMIT_TIERS: Record<string, LimitTier> = {
ANONYMOUS: {
daily: 500,
monthly: 1000,
perTransaction: 500,
fiatsAllowed: false,
cryptoWithdrawalLimit: 500,
requiresKYC: KYCLevel.NONE,
},
BASIC: { // email верифіковано + AML screening
daily: 2000,
monthly: 5000,
perTransaction: 2000,
fiatsAllowed: false,
cryptoWithdrawalLimit: 5000,
requiresKYC: KYCLevel.EMAIL,
},
VERIFIED: { // full KYC
daily: 50000,
monthly: 200000,
perTransaction: 25000,
fiatsAllowed: true,
cryptoWithdrawalLimit: -1, // без ліміту
requiresKYC: KYCLevel.FULL,
},
};
Rolling window лімiти
Фіксовані денні вікна (00:00-23:59) створюють погану UX: користувач не може обміняти в 23:50 то що планував, тому що ліміт обнулиться через 10 хвилин. Rolling window краще:
class LimitChecker {
async checkAndConsumeLimits(
userId: string,
amount: number,
currency: string
): Promise<LimitCheckResult> {
const tier = await this.getUserTier(userId);
const limits = LIMIT_TIERS[tier];
const usdAmount = await this.toUSD(amount, currency);
// Перевірка однієї транзакції
if (usdAmount > limits.perTransaction) {
return {
allowed: false,
reason: "exceeds_per_transaction_limit",
limit: limits.perTransaction,
upgradeRequired: tier !== "VERIFIED",
};
}
// Rolling 24h вікно
const usage24h = await this.getUsage(userId, 24 * 60 * 60 * 1000);
if (usage24h + usdAmount > limits.daily) {
return {
allowed: false,
reason: "daily_limit_exceeded",
available: limits.daily - usage24h,
resetsIn: await this.getNextResetTime(userId, "daily"),
};
}
// Rolling 30d вікно
const usage30d = await this.getUsage(userId, 30 * 24 * 60 * 60 * 1000);
if (usage30d + usdAmount > limits.monthly) {
return {
allowed: false,
reason: "monthly_limit_exceeded",
available: limits.monthly - usage30d,
};
}
// Якщо всё ок — резервуємо (idempotency через Redis)
await this.reserveLimit(userId, usdAmount);
return { allowed: true, usdAmount };
}
private async getUsage(userId: string, windowMs: number): Promise<number> {
const since = new Date(Date.now() - windowMs);
return this.db.sumTransactions(userId, since);
}
}
AML пороги та автоматичні перевірки
const AML_THRESHOLDS = {
ENHANCED_SCREENING: 1000, // USD — додатковий AML скрининг
KYC_REQUIRED: 1000, // вимагає базовий KYC
FULL_KYC_REQUIRED: 3000, // вимагає повний KYC
SAR_REVIEW: 10000, // ручна review compliance офіцером
CTR_REPORT: 10000, // Currency Transaction Report (у деяких юрисдикціях)
};
async function preTransactionChecks(tx: ExchangeTransaction): Promise<CheckResult> {
// Автоматичне підвищення вимог при досягненні порогів
if (tx.usdAmount >= AML_THRESHOLDS.FULL_KYC_REQUIRED) {
const kycStatus = await getKYCStatus(tx.userId);
if (kycStatus < KYCLevel.FULL) {
return {
action: "REQUIRE_KYC",
requiredLevel: KYCLevel.FULL,
message: "Для суми свище $3,000 потрібна верифікація",
};
}
}
// Скрининг при сумах вище $1,000
if (tx.usdAmount >= AML_THRESHOLDS.ENHANCED_SCREENING) {
const screenResult = await screenWallet(tx.destinationAddress, tx.asset);
if (screenResult.blocked) {
return { action: "BLOCK", reason: screenResult.reason };
}
}
return { action: "ALLOW" };
}
Верифікація за джерелом коштів
Для сум вище enhanced due diligence порогу — форма Source of Funds:
interface SourceOfFunds {
source: "employment" | "business" | "investments" | "inheritance" | "other";
description: string;
estimatedMonthlyVolume: number;
supportingDocuments: string[]; // IPFS hashes або S3 URLs
}
async function collectSourceOfFunds(userId: string, amount: number): Promise<boolean> {
if (amount < SOF_THRESHOLD) return true;
const existingSOF = await db.getSourceOfFunds(userId);
// SOF дійсний якщо заповнений та не істік (пересбір раз на рік)
if (existingSOF && !isExpired(existingSOF, 365)) return true;
// Запрошуємо SOF через UI
await triggerSOFCollection(userId, { requiredFor: "transaction", amount });
return false;
}
Система лімітів з rolling windows, AML порогами та SOF collection — 2-3 тижні розробки.







