Exchanger Limits and Verification System Development
A limits system is not just numbers in a database. It is a compliance tool that balances between usability (user wants to exchange immediately) and regulatory requirements (AML requires knowing the client at amounts above threshold). Proper limits architecture reduces KYC abandonment and remains compliant.
Limit Structure by Verification Level
interface LimitTier {
daily: number; // USD equivalent
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 verified + 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, // unlimited
requiresKYC: KYCLevel.FULL,
},
};
Rolling Window Limits
Fixed daily windows (00:00-23:59) create bad UX: user cannot exchange at 23:50 what was planned because limit resets in 10 minutes. Rolling window is better:
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);
// Single transaction check
if (usdAmount > limits.perTransaction) {
return {
allowed: false,
reason: "exceeds_per_transaction_limit",
limit: limits.perTransaction,
upgradeRequired: tier !== "VERIFIED",
};
}
// Rolling 24h window
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 window
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,
};
}
// If all ok — reserve (idempotency via 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 Threshold Amounts and Automatic Checks
const AML_THRESHOLDS = {
ENHANCED_SCREENING: 1000, // USD — additional AML screening
KYC_REQUIRED: 1000, // requires basic KYC
FULL_KYC_REQUIRED: 3000, // requires full KYC
SAR_REVIEW: 10000, // manual review by compliance officer
CTR_REPORT: 10000, // Currency Transaction Report (in some jurisdictions)
};
async function preTransactionChecks(tx: ExchangeTransaction): Promise<CheckResult> {
// Automatic requirement escalation when thresholds reached
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: "For amounts above $3,000, verification is required",
};
}
}
// Screening at amounts above $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" };
}
Source of Funds Verification
For amounts above enhanced due diligence threshold — Source of Funds form:
interface SourceOfFunds {
source: "employment" | "business" | "investments" | "inheritance" | "other";
description: string;
estimatedMonthlyVolume: number;
supportingDocuments: string[]; // IPFS hashes or S3 URLs
}
async function collectSourceOfFunds(userId: string, amount: number): Promise<boolean> {
if (amount < SOF_THRESHOLD) return true;
const existingSOF = await db.getSourceOfFunds(userId);
// SOF valid if filled and not expired (re-collection once per year)
if (existingSOF && !isExpired(existingSOF, 365)) return true;
// Request SOF through UI
await triggerSOFCollection(userId, { requiredFor: "transaction", amount });
return false;
}
Limits system with rolling windows, AML thresholds and SOF collection — 2-3 weeks development.







