MiCA Article Mapping
Complete article-by-article code mapping of Regulation (EU) 2023/1114 — Markets in Crypto-Assets.
Source: Regulation (EU) 2023/1114|In force: 29 June 2023|Fully applicable: 30 December 2024
Quick Reference — Key Thresholds & Deadlines
Essential MiCA numbers every implementer needs to know
| Article | Threshold / Deadline |
|---|---|
| Art. 4.2 | Whitepaper exemption: ≤ EUR 1,000,000 over 12 months |
| Art. 4.2 | Per-MS exemption: < 150 persons per Member State |
| Art. 4.2 | NCA notification: ≥ 20 WORKING DAYS before publication |
| Art. 9 | Retail withdrawal period: 14 CALENDAR DAYS |
| Art. 9 | Refund deadline: 14 CALENDAR DAYS from notice |
| Art. 17 | ART authorization decision: 60 WORKING DAYS |
| Art. 22 | ART own funds floor: EUR 350,000 |
| Art. 22 | ART own funds: 2% of 6-month average reserve |
| Art. 22 | Significant ART own funds: 3% of reserve |
| Art. 23 | ART overnight deposits: ≥ 60% of reserve |
| Art. 23 | ART concentration limit: ≤ 10% per institution |
| Art. 28 | ART redemption: 10 WORKING DAYS |
| Art. 43 | Significant ART: ≥ 3 of 7 criteria |
| Art. 43 | Holders threshold: > 10,000,000 |
| Art. 43 | Market cap threshold: > EUR 5,000,000,000 |
| Art. 48 | EMT NCA notification: ≥ 40 WORKING DAYS |
| Art. 53 | EMT redemption: 1 BUSINESS DAY at par value |
| Art. 59 | CASP authorization: 40 WORKING DAYS |
| Art. 67 | CASP Tier 1: EUR 50,000 |
| Art. 67 | CASP Tier 2: EUR 125,000 |
| Art. 67 | CASP Tier 3: EUR 150,000 |
| Art. 69 | Complaint acknowledgment: 5 BUSINESS DAYS |
| Art. 69 | Complaint response: 15 BUSINESS DAYS (extendable to 35) |
| Art. 69 | Complaint records: 5 YEARS retention |
Article-by-Article Mapping
Title II — Crypto-Assets (not ART/EMT) — Art. 4–15
Conditions for Public Offering
A person may offer crypto-assets to the public in the EU only if they are a legal entity, have prepared a compliant white paper, notified the NCA at least 20 working days before publication, and published the white paper.
Obligations
- Offeror must be a legal entity (natural persons cannot be offerors)
- Prepare compliant white paper (Art. 6)
- Notify competent authority (NCA) at least 20 WORKING DAYS before publication
- Publish white paper on website (machine-readable)
Key Thresholds
Exemption threshold: ≤ EUR 1,000,000 over 12 months
Per-MS exemption: < 150 persons per Member State
NCA notification: ≥ 20 WORKING DAYS before publication
import { z } from "zod"
const OfferingEligibilitySchema = z.object({
offerorIsLegalEntity: z.boolean(),
totalConsiderationEUR_12months: z.number().nonnegative(),
targetAudience: z.enum([
"general_public", // requires whitepaper
"qualified_investors", // exempt
"max_150_persons", // exempt if < 150/MS
"free_distribution", // exempt
"mining_reward", // exempt
"existing_utility" // exempt if goods/services live
]),
personsPerMemberState: z.number().int().nonnegative(),
memberStatesTargeted: z.array(z.string().length(2)), // ISO 3166
whitePaperPublished: z.boolean(),
ncaNotificationDate: z.string().date().optional(),
// Computed: notificationDeadline = publicationDate - 20 working days
})
function checkOfferingEligibility(data: OfferingEligibility): {
whitepaperRequired: boolean
exemptionBasis: string | null
ncaNotificationDeadline: Date | null
violations: string[]
}White Paper — Mandatory Content (+ Annex I)
The white paper must contain 7 mandatory sections: issuer info, offeror info, trading platform operator, project description, token description, risks, and environmental information.
Obligations
- Section 1: Issuer information (name, LEI, registered address, management body, audited financial statements)
- Section 2: Offeror information (if different from issuer)
- Section 3: Trading platform operator information
- Section 4: Project description (reasons, use of proceeds, technology)
- Section 5: Token description (supply, rights, obligations, smart contract address)
- Section 6: Risks (issuer, token, offering, technology, regulatory)
- Section 7: Environmental information (consensus mechanism, energy consumption for PoW)
- Plain-language summary (max 2 pages)
- Mandatory warning about no investor protection
import { z } from "zod"
const LEI_REGEX = /^[A-Z0-9]{18}[0-9]{2}$/
const IssuerSchema = z.object({
legalName: z.string().min(1),
legalForm: z.string().min(1),
lei: z.string().regex(LEI_REGEX).optional(),
registeredAddress: z.object({
street: z.string(),
city: z.string(),
country: z.string().length(2),
postcode: z.string(),
}),
ncaNotified: z.string().length(2), // MS where NCA notified
managementBody: z.array(z.object({
name: z.string(),
role: z.string(),
dob: z.string().date().optional(),
})).min(1),
websiteUrl: z.string().url(),
financialStatements: z.string().url().optional(),
})
const ConsensusMechanismEnum = z.enum([
"PoS", "PoW", "dPoS", "PoA", "DAG", "other"
])
const WhitePaperSchema = z.object({
// Metadata
version: z.string().default("1.0"),
language: z.string().default("en"),
publicationDate: z.string().datetime().optional(),
documentHash: z.string().length(64).optional(), // SHA-256 hex
// Section 1 & 2
issuer: IssuerSchema,
offeror: IssuerSchema.optional(),
// Section 4 — Project
reasonForOffer: z.string().min(50),
useOfProceeds: z.string().min(50),
technologyDescription: z.string().min(100),
// Section 5 — Token
tokenName: z.string().min(1),
tokenSymbol: z.string().max(10),
tokenType: z.enum(["utility", "payment", "other_crypto_asset"]),
totalSupply: z.number().positive(),
issuanceSchedule: z.string(),
distributionPlan: z.array(z.object({
recipient: z.string(),
amount: z.number(),
vestingMonths: z.number().int().nonnegative(),
percentage: z.number().min(0).max(100),
})),
holderRights: z.array(z.string()).min(1),
holderObligations: z.array(z.string()),
transferable: z.boolean(),
dltProtocol: z.string(),
smartContractAddress: z.string().optional(),
smartContractAuditUrl: z.string().url().optional(),
// Section 6 — Risks
risks: z.object({
issuerRisks: z.array(z.string()).min(1),
tokenRisks: z.array(z.string()).min(1),
offeringRisks: z.array(z.string()).min(1),
technologyRisks: z.array(z.string()).min(1),
regulatoryRisks: z.array(z.string()).min(1),
}),
// Section 7 — Environmental
consensusMechanism: ConsensusMechanismEnum,
annualEnergyKWh: z.number().nonnegative().optional(),
carbonFootprintTons: z.number().nonnegative().optional(),
})
// Compliance validator
function validateWhitePaper(wp: WhitePaper): {
valid: boolean
missingFields: string[]
warnings: string[]
} {
const missing: string[] = []
const warnings: string[] = []
if (wp.consensusMechanism === "PoW" && !wp.annualEnergyKWh) {
missing.push("annualEnergyKWh required for PoW tokens (Art. 6.1.h)")
}
if (!wp.smartContractAuditUrl) {
warnings.push("Smart contract audit URL recommended")
}
if (wp.distributionPlan.reduce((s, d) => s + d.percentage, 0) !== 100) {
missing.push("Distribution plan percentages must sum to 100%")
}
return { valid: missing.length === 0, missingFields: missing, warnings }
}Right of Withdrawal (Retail Purchasers)
Retail purchasers have the right to withdraw from purchase within 14 calendar days, with full refund due within 14 calendar days of withdrawal notice.
Obligations
- Right to withdraw within 14 CALENDAR DAYS from purchase or white paper publication (whichever is later)
- No justification required
- Full refund within 14 CALENDAR DAYS from withdrawal notice (Art. 13(2))
- Refund includes all charges paid, no penalty
Key Thresholds
Withdrawal period: 14 CALENDAR DAYS from purchase
Refund deadline: 14 CALENDAR DAYS from withdrawal notice
import { z } from "zod"
import { addDays } from "date-fns"
const PurchaseSchema = z.object({
purchaseId: z.string().uuid(),
purchaserId: z.string(),
offeringId: z.string(),
purchaseDate: z.string().datetime(),
whitepaperPublicationDate: z.string().datetime(),
amountEUR: z.number().positive(),
tokenAmount: z.number().positive(),
transactionCostEUR: z.number().nonnegative(),
isRetailPurchaser: z.boolean(),
isSecondaryMarket: z.boolean(),
})
function getWithdrawalDeadline(purchase: Purchase): Date {
// 14 calendar days from later of: purchaseDate or whitepaper publication
const startDate = new Date(Math.max(
new Date(purchase.purchaseDate).getTime(),
new Date(purchase.whitepaperPublicationDate).getTime()
))
return addDays(startDate, 14)
}
function getRefundDeadline(withdrawalNoticeDate: Date): Date {
// 14 calendar days from date offeror is informed (Art. 13(2))
return addDays(withdrawalNoticeDate, 14)
}
const WithdrawalSchema = z.object({
withdrawalId: z.string().uuid(),
purchaseId: z.string().uuid(),
noticeDate: z.string().datetime(),
deadline: z.string().datetime(),
refundDue: z.number(), // amountEUR + transactionCostEUR
refundDeadline: z.string().datetime(),
status: z.enum(["open", "withdrawn", "deadline_passed", "refunded"]),
})Title III — Asset-Referenced Tokens (ART) — Art. 16–47
Own Funds Requirement for ART Issuers
ART issuers must maintain own funds equal to the higher of EUR 350,000 or 2% (3% for significant ART) of the average reserve over the preceding 6 months.
Obligations
- Own funds ≥ MAX(EUR 350,000, 2% of 6-month average reserve)
- For Significant ART (Art. 39): 3% instead of 2%
- Own funds = CET1 instruments only
- Daily reserve snapshots for 6-month rolling average calculation
Key Thresholds
Minimum floor: EUR 350,000
Standard percentage: 2% of average reserve (6 months)
Significant ART percentage: 3% of average reserve
import { z } from "zod"
// Daily reserve snapshot for 6-month rolling average
const ReserveSnapshotSchema = z.object({
date: z.string().date(),
totalReserveEUR: z.number().nonnegative(),
})
function calculateRequiredOwnFunds(
dailySnapshots: ReserveSnapshot[], // last 180 calendar days
isSignificant: boolean
): {
averageReserve: number
percentageRequirement: number // 2% or 3%
calculatedRequirement: number // avg * pct
minimumFloor: number // EUR 350,000
requiredOwnFunds: number // max(floor, calculated)
breachThreshold: number // trigger warning at 110%
} {
const avg = dailySnapshots.reduce((s, d) => s + d.totalReserveEUR, 0)
/ dailySnapshots.length
const pct = isSignificant ? 0.03 : 0.02
const calculated = avg * pct
const required = Math.max(350_000, calculated)
return {
averageReserve: avg,
percentageRequirement: pct,
calculatedRequirement: calculated,
minimumFloor: 350_000,
requiredOwnFunds: required,
breachThreshold: required * 1.1, // warn at 110%
}
}Reserve of Assets — Composition Rules
Reserve must be at least 60% overnight deposits at EU credit institutions, with remaining 40% in highly liquid low-risk assets. No crypto-assets allowed.
Obligations
- At least 60% overnight deposits at EU-authorised credit institutions
- Up to 40% in UCITS money market funds, EU government bonds, or central bank deposits
- No investment in crypto-assets, derivatives, or high-risk instruments
- Currency matching: reserve assets in same currencies as reference basket
- Geographic diversification: no >10% at single credit institution
- Daily reconciliation: outstanding tokens vs total reserve value
- Quarterly public disclosure (Art. 31)
Key Thresholds
Overnight deposit minimum: ≥ 60% of reserve
Concentration limit: ≤ 10% at single institution
Significant ART liquidity buffer: Additional 3% in deposits
import { z } from "zod"
const AssetCategoryEnum = z.enum([
"overnight_deposit_credit_institution",
"ucits_money_market_fund",
"eu_government_bond",
"central_bank_deposit",
"other" // non-compliant if >0
])
const ReserveAssetSchema = z.object({
assetId: z.string().uuid(),
category: AssetCategoryEnum,
institution: z.string(),
countryCode: z.string().length(2), // must be EU
currency: z.string().length(3), // ISO 4217
amountEUR: z.number().nonnegative(),
isinCode: z.string().optional(),
maturityDate: z.string().date().optional(),
isOvernightDeposit: z.boolean(),
custodian: z.string(),
verifiedAt: z.string().datetime(),
})
const ReserveStateSchema = z.object({
snapshotDate: z.string().datetime(),
totalTokensCirculating: z.number().nonnegative(),
referenceCurrencies: z.array(z.object({
currency: z.string().length(3),
weight: z.number().min(0).max(1),
})),
assets: z.array(ReserveAssetSchema),
})
function validateReserveCompliance(state: ReserveState): {
art23_overnight_min60pct: { pass: boolean; actual: number; required: 0.60 }
art23_no_crypto_assets: { pass: boolean; violations: string[] }
art23_concentration: { pass: boolean; maxSingleInstitution: number }
art23_currency_match: { pass: boolean; mismatches: string[] }
art23_full_coverage: { pass: boolean; coverageRatio: number }
overallCompliant: boolean
} {
const total = state.assets.reduce((s, a) => s + a.amountEUR, 0)
const overnight = state.assets
.filter(a => a.isOvernightDeposit)
.reduce((s, a) => s + a.amountEUR, 0)
// Concentration check: no single institution > 10%
const byInstitution: Record<string, number> = {}
state.assets.forEach(a => {
byInstitution[a.institution] = (byInstitution[a.institution] || 0) + a.amountEUR
})
const maxConcentration = Math.max(...Object.values(byInstitution)) / total
return {
art23_overnight_min60pct: {
pass: overnight / total >= 0.60,
actual: overnight / total,
required: 0.60
},
art23_no_crypto_assets: {
pass: !state.assets.some(a => a.category === "other"),
violations: state.assets.filter(a => a.category === "other").map(a => a.assetId)
},
art23_concentration: {
pass: maxConcentration <= 0.10,
maxSingleInstitution: maxConcentration
},
art23_currency_match: {
pass: true,
mismatches: []
},
art23_full_coverage: {
pass: total >= state.totalTokensCirculating,
coverageRatio: total / Math.max(1, state.totalTokensCirculating)
},
overallCompliant: overnight / total >= 0.60 && maxConcentration <= 0.10
}
}ART Holder Rights & Redemption
Holders may redeem at any time with no lock-up. Redemption in reference assets or fiat equivalent within 10 working days. No interest or yield permitted.
Obligations
- Holders may redeem at ANY TIME (no lock-up permitted)
- Redemption in reference assets OR their fiat equivalent at market value
- Deadline: within 10 WORKING DAYS from redemption request
- No fees unless explicitly stated in white paper (proportionate only)
- PROHIBITION on interest: holders may NOT receive interest or yield
Key Thresholds
Redemption deadline: 10 WORKING DAYS from request
Interest/yield: PROHIBITED (Art. 29)
import { z } from "zod"
import { addBusinessDays } from "date-fns"
const ARTRedemptionSchema = z.object({
requestId: z.string().uuid(),
holderId: z.string(),
tokenAmount: z.number().positive(),
requestedAt: z.string().datetime(),
mode: z.enum(["reference_assets", "fiat_equivalent"]),
destinationAccount: z.string(),
marketValueEUR: z.number().optional(),
feeEUR: z.number().nonnegative().default(0),
netAmountEUR: z.number().optional(),
workingDayDeadline: z.string().datetime(),
status: z.enum([
"pending",
"processing",
"completed",
"rejected",
"deadline_breached"
]),
})
function getARTRedemptionDeadline(requestDate: Date): Date {
// 10 working days from request
return addBusinessDays(requestDate, 10)
}
// Art. 29 — Interest prohibition check
const InterestProhibitionSchema = z.object({
tokenId: z.string(),
holderAddress: z.string(),
interestPaid: z.literal(0), // Must always be 0
yieldGenerated: z.literal(0), // Must always be 0
stakingRewards: z.literal(0), // Must always be 0
})Title IV — E-Money Tokens (EMT) — Art. 48–58
EMT Redemption Rights
The most technically precise article in MiCA: EMT must be redeemable at par value (1:1 with reference fiat), on demand, within 1 business day, with no fees.
Obligations
- Right to redeem at ANY TIME, on demand, at PAR VALUE
- PAR VALUE = 1 token = 1 unit of reference fiat currency (no spread)
- Deadline: within 1 BUSINESS DAY of redemption request
- Fees: NONE (unless stated in white paper — fee cannot reduce below par)
- Funds held in segregated accounts or invested in highly liquid, low-risk assets
Key Thresholds
Redemption deadline: 1 BUSINESS DAY
Exchange rate: Par value (1:1)
Fees: None (or cannot reduce below par)
import { z } from "zod"
import { addBusinessDays, isAfter } from "date-fns"
const EMTRedemptionSchema = z.object({
requestId: z.string().uuid(),
holderId: z.string(),
tokenAmount: z.number().positive(),
referenceCurrency: z.string().length(3),
requestedAt: z.string().datetime(),
// PAR VALUE: 1 token = 1 unit of fiat
parValuePerToken: z.literal(1),
totalFiatAmount: z.number(), // = tokenAmount * 1
feeEUR: z.number().min(0).max(0), // Must be 0
netAmount: z.number(), // = totalFiatAmount (no deduction)
destinationIBAN: z.string().regex(/^[A-Z]{2}[0-9]{2}[A-Z0-9]{4,30}$/),
deadline: z.string().datetime(), // 1 business day
status: z.enum([
"pending",
"processing",
"completed",
"sla_breached"
]),
})
function createEMTRedemption(
tokenAmount: number,
currency: string,
requestDate: Date
): EMTRedemption {
const deadline = addBusinessDays(requestDate, 1)
return {
requestId: crypto.randomUUID(),
tokenAmount,
referenceCurrency: currency,
parValuePerToken: 1,
totalFiatAmount: tokenAmount * 1, // Par value
feeEUR: 0,
netAmount: tokenAmount, // Full amount, no fees
deadline: deadline.toISOString(),
status: "pending",
}
}
function checkSLABreach(redemption: EMTRedemption): boolean {
return redemption.status === "pending" &&
isAfter(new Date(), new Date(redemption.deadline))
}Prohibition on Interest/Yield
EMT issuers are prohibited from paying interest, yield, staking rewards, or any economic equivalent. EMT must function as digital cash, not investment.
Obligations
- PROHIBITED: Paying interest to token holders
- PROHIBITED: Providing yield, staking rewards, or any return
- PROHIBITED: Any arrangement producing economic equivalent of interest
- PROHIBITED: DeFi integrations (yield farming, liquidity provision) by issuer
Key Thresholds
Interest/yield: PROHIBITED
import { z } from "zod"
// Art. 55 compliance schema — all yield fields must be zero
const EMTYieldComplianceSchema = z.object({
tokenId: z.string(),
periodStart: z.string().date(),
periodEnd: z.string().date(),
// ALL of these MUST be zero for compliance
interestPaid: z.literal(0),
yieldDistributed: z.literal(0),
stakingRewards: z.literal(0),
liquidityRewards: z.literal(0),
anyOtherReturn: z.literal(0),
})
// Compliance check function
function validateArt55Compliance(
tokenId: string,
transactions: Transaction[]
): {
compliant: boolean
violations: string[]
} {
const violations: string[] = []
const yieldTransactions = transactions.filter(tx =>
tx.type === "interest" ||
tx.type === "yield" ||
tx.type === "staking_reward" ||
tx.type === "liquidity_reward"
)
if (yieldTransactions.length > 0) {
violations.push(
`Art. 55 violation: ${yieldTransactions.length} yield-bearing transactions detected`
)
}
return {
compliant: violations.length === 0,
violations,
}
}Title V — Crypto-Asset Service Providers (CASP) — Art. 59–85
Own Funds Requirements for CASP
Own funds must be at least the higher of the tier minimum (based on services) or 1/4 of annual fixed overhead.
Obligations
- Own funds ≥ MAX(tier minimum, ¼ × fixed overhead)
- Tier 1 (EUR 50,000): Advice, reception/transmission, execution, placing, transfer
- Tier 2 (EUR 125,000): Exchange (fiat/crypto), portfolio management, custody
- Tier 3 (EUR 150,000): Trading platform operation
- If multiple services → highest applicable tier
Key Thresholds
Tier 1 minimum: EUR 50,000
Tier 2 minimum: EUR 125,000
Tier 3 minimum: EUR 150,000
Alternative calculation: ¼ of prior year fixed costs
import { z } from "zod"
const CASPServiceEnum = z.enum([
// Tier 1 — EUR 50,000
"advice",
"reception_transmission",
"execution",
"placing",
"transfer",
// Tier 2 — EUR 125,000
"exchange_fiat",
"exchange_crypto",
"portfolio_management",
"custody",
// Tier 3 — EUR 150,000
"trading_platform",
])
const TIER_MINIMUMS = {
tier1: 50_000,
tier2: 125_000,
tier3: 150_000,
} as const
const SERVICE_TIERS: Record<z.infer<typeof CASPServiceEnum>, keyof typeof TIER_MINIMUMS> = {
advice: "tier1",
reception_transmission: "tier1",
execution: "tier1",
placing: "tier1",
transfer: "tier1",
exchange_fiat: "tier2",
exchange_crypto: "tier2",
portfolio_management: "tier2",
custody: "tier2",
trading_platform: "tier3",
}
function calculateCASPOwnFunds(
services: z.infer<typeof CASPServiceEnum>[],
annualFixedOverhead: number
): {
highestTier: keyof typeof TIER_MINIMUMS
tierMinimum: number
quarterOverhead: number
requiredOwnFunds: number
} {
// Determine highest tier from selected services
const tiers = services.map(s => SERVICE_TIERS[s])
const highestTier = tiers.includes("tier3") ? "tier3"
: tiers.includes("tier2") ? "tier2" : "tier1"
const tierMinimum = TIER_MINIMUMS[highestTier]
const quarterOverhead = annualFixedOverhead / 4
return {
highestTier,
tierMinimum,
quarterOverhead,
requiredOwnFunds: Math.max(tierMinimum, quarterOverhead),
}
}Complaint Handling Procedure
Free complaint handling with strict SLAs: acknowledge within 5 business days, respond within 15 business days (extendable to 35). Records kept for 5 years.
Obligations
- Effective and transparent complaint procedure
- FREE OF CHARGE to clients
- Acknowledgment: within 5 BUSINESS DAYS
- Response: within 15 BUSINESS DAYS
- Extension: up to 35 BUSINESS DAYS (must notify on day 15 with reason)
- Keep records for 5 YEARS
- Report complaint stats to NCA
Key Thresholds
Acknowledgment deadline: 5 BUSINESS DAYS
Standard response deadline: 15 BUSINESS DAYS
Extended response deadline: 35 BUSINESS DAYS
Record retention: 5 YEARS
import { z } from "zod"
import { addBusinessDays, differenceInBusinessDays, isAfter } from "date-fns"
const ComplaintSchema = z.object({
complaintId: z.string().uuid(),
clientId: z.string(),
submittedAt: z.string().datetime(),
subject: z.string().min(10),
description: z.string().min(50),
// SLA tracking
ackDeadline: z.string().datetime(), // +5 business days
ackSentAt: z.string().datetime().optional(),
responseDeadline: z.string().datetime(), // +15 business days
extendedDeadline: z.string().datetime().optional(), // +35 if extended
extensionReason: z.string().optional(),
responseSentAt: z.string().datetime().optional(),
// Status
status: z.enum([
"received",
"acknowledged",
"under_review",
"extended",
"resolved",
"sla_breached"
]),
// Retention: 5 years from resolution
retentionUntil: z.string().datetime().optional(),
})
function createComplaint(clientId: string, subject: string, description: string): Complaint {
const now = new Date()
return {
complaintId: crypto.randomUUID(),
clientId,
submittedAt: now.toISOString(),
subject,
description,
ackDeadline: addBusinessDays(now, 5).toISOString(),
responseDeadline: addBusinessDays(now, 15).toISOString(),
status: "received",
}
}
function checkSLAStatus(complaint: Complaint): {
ackOverdue: boolean
responseOverdue: boolean
daysUntilAck: number
daysUntilResponse: number
} {
const now = new Date()
const ackDeadline = new Date(complaint.ackDeadline)
const responseDeadline = new Date(
complaint.extendedDeadline || complaint.responseDeadline
)
return {
ackOverdue: !complaint.ackSentAt && isAfter(now, ackDeadline),
responseOverdue: !complaint.responseSentAt && isAfter(now, responseDeadline),
daysUntilAck: differenceInBusinessDays(ackDeadline, now),
daysUntilResponse: differenceInBusinessDays(responseDeadline, now),
}
}Suitability Assessment (Portfolio Mgmt & Advice)
Before providing advice or portfolio management to retail clients, must collect information on knowledge, financial situation, objectives, and loss capacity.
Obligations
- Collect information before service delivery:
- (a) Knowledge and experience with crypto-assets
- (b) Financial situation: income, assets, liabilities
- (c) Investment objectives: purpose, time horizon, risk tolerance
- (d) Ability to bear losses: % of portfolio client can lose
- Issue SUITABILITY REPORT before each service
- Warning if client purchases non-suitable product
- Periodic reviews for portfolio management (at least annually)
import { z } from "zod"
const KnowledgeAssessmentSchema = z.object({
yearsOfExperience: z.number().int().min(0),
cryptoTypesTraded: z.array(z.enum([
"bitcoin", "ethereum", "stablecoins", "defi", "nfts", "other"
])),
understandsVolatility: z.boolean(),
understandsPrivateKeys: z.boolean(),
previousLosses: z.boolean(),
selfAssessedLevel: z.enum(["novice", "intermediate", "advanced"]),
})
const FinancialSituationSchema = z.object({
annualIncomeEUR: z.number().nonnegative(),
liquidAssetsEUR: z.number().nonnegative(),
totalLiabilitiesEUR: z.number().nonnegative(),
monthlyExpensesEUR: z.number().nonnegative(),
emergencyFundMonths: z.number().int().min(0),
dependents: z.number().int().min(0),
})
const InvestmentObjectivesSchema = z.object({
purpose: z.enum([
"capital_growth",
"income_generation",
"speculation",
"hedging",
"diversification"
]),
timeHorizon: z.enum([
"less_than_1_year",
"1_to_3_years",
"3_to_5_years",
"more_than_5_years"
]),
riskTolerance: z.enum(["low", "medium", "high"]),
maxPortfolioAllocation: z.number().min(0).max(100),
})
const LossCapacitySchema = z.object({
maxAcceptableLossPct: z.number().min(0).max(100),
canAffordTotalLoss: z.boolean(),
impactOnLifestyle: z.enum(["none", "minor", "significant", "severe"]),
})
const SuitabilityAssessmentSchema = z.object({
assessmentId: z.string().uuid(),
clientId: z.string(),
assessedAt: z.string().datetime(),
knowledge: KnowledgeAssessmentSchema,
financialSituation: FinancialSituationSchema,
objectives: InvestmentObjectivesSchema,
lossCapacity: LossCapacitySchema,
suitabilityScore: z.number().min(0).max(100),
recommendation: z.enum(["suitable", "potentially_suitable", "not_suitable"]),
warnings: z.array(z.string()),
nextReviewDue: z.string().datetime(), // At least annually
})
function calculateSuitabilityScore(assessment: SuitabilityAssessment): number {
let score = 50 // Base score
// Knowledge adjustments
if (assessment.knowledge.yearsOfExperience >= 3) score += 15
if (assessment.knowledge.understandsVolatility) score += 10
// Financial health adjustments
const netWorth = assessment.financialSituation.liquidAssetsEUR
- assessment.financialSituation.totalLiabilitiesEUR
if (netWorth > 100_000) score += 10
if (assessment.financialSituation.emergencyFundMonths >= 6) score += 10
// Risk tolerance alignment
if (assessment.objectives.riskTolerance === "low") score -= 15
if (assessment.lossCapacity.canAffordTotalLoss) score += 10
return Math.max(0, Math.min(100, score))
}Title VI — Market Abuse Prevention — Art. 86–92
Insider Dealing Prohibition
Prohibition on trading crypto-assets while in possession of inside information. Applies to all persons, not just issuers/CASPs.
Obligations
- PROHIBITED: Acquiring/disposing crypto-assets using inside information
- PROHIBITED: Recommending/inducing others to trade based on inside information
- PROHIBITED: Disclosing inside information except in normal course of duties
- Inside information = precise, non-public, price-sensitive
- Applies to ALL persons (not just issuers/CASPs)
import { z } from "zod"
const InsideInformationSchema = z.object({
informationId: z.string().uuid(),
cryptoAssetId: z.string(),
description: z.string().min(20),
// Criteria for "inside information"
isPrecise: z.boolean(), // Specific enough to conclude price effect
isNonPublic: z.boolean(), // Not yet disclosed to market
isPriceSensitive: z.boolean(), // Would likely affect price if disclosed
discoveredAt: z.string().datetime(),
disclosedAt: z.string().datetime().optional(),
source: z.enum([
"issuer_employee",
"casp_employee",
"advisor",
"third_party",
"technical_discovery"
]),
})
const InsiderTradeCheckSchema = z.object({
tradeId: z.string().uuid(),
traderId: z.string(),
cryptoAssetId: z.string(),
tradeTimestamp: z.string().datetime(),
direction: z.enum(["buy", "sell"]),
amount: z.number().positive(),
// Compliance fields
hadInsideInfo: z.boolean(),
insideInfoIds: z.array(z.string().uuid()),
complianceCheck: z.enum([
"clear", // No inside info
"flagged", // Potential violation
"confirmed_breach" // Verified Art. 88 breach
]),
})
function checkInsiderTrade(
trade: Trade,
activeInsideInfo: InsideInformation[]
): {
violation: boolean
matchingInfo: InsideInformation[]
recommendedAction: string
} {
const matching = activeInsideInfo.filter(info =>
info.cryptoAssetId === trade.cryptoAssetId &&
info.isPrecise && info.isNonPublic && info.isPriceSensitive &&
!info.disclosedAt // Still non-public
)
return {
violation: matching.length > 0,
matchingInfo: matching,
recommendedAction: matching.length > 0
? "HALT_TRADE_AND_REPORT_TO_NCA"
: "PROCEED",
}
}Market Manipulation Prohibition
Comprehensive prohibition on market manipulation including wash trading, layering, spoofing, and dissemination of false information.
Obligations
- PROHIBITED: Wash trading (transactions with no economic purpose)
- PROHIBITED: Layering/spoofing (orders intended to be cancelled)
- PROHIBITED: Pump-and-dump schemes
- PROHIBITED: Coordinated trading to create artificial price
- PROHIBITED: Disseminating false or misleading information
- Transaction surveillance systems mandatory for CASPs
import { z } from "zod"
const ManipulationTypeEnum = z.enum([
"wash_trading",
"layering",
"spoofing",
"pump_and_dump",
"coordinated_trading",
"false_information",
])
const SuspiciousPatternSchema = z.object({
patternId: z.string().uuid(),
detectedAt: z.string().datetime(),
cryptoAssetId: z.string(),
manipulationType: ManipulationTypeEnum,
confidence: z.number().min(0).max(1),
indicators: z.array(z.string()),
involvedAddresses: z.array(z.string()),
volumeAffected: z.number().nonnegative(),
priceImpactPct: z.number(),
status: z.enum([
"detected",
"under_review",
"confirmed",
"dismissed",
"reported_to_nca"
]),
})
// Wash trading detection
function detectWashTrading(
trades: Trade[],
timeWindowMinutes: number = 60
): SuspiciousPattern[] {
const patterns: SuspiciousPattern[] = []
// Group trades by address pairs
const addressPairs = new Map<string, Trade[]>()
trades.forEach(trade => {
const key = [trade.fromAddress, trade.toAddress].sort().join("|")
if (!addressPairs.has(key)) addressPairs.set(key, [])
addressPairs.get(key)!.push(trade)
})
// Check for circular patterns
addressPairs.forEach((pairTrades, key) => {
if (pairTrades.length >= 3) {
const buys = pairTrades.filter(t => t.direction === "buy")
const sells = pairTrades.filter(t => t.direction === "sell")
if (buys.length > 0 && sells.length > 0) {
patterns.push({
patternId: crypto.randomUUID(),
detectedAt: new Date().toISOString(),
cryptoAssetId: pairTrades[0].cryptoAssetId,
manipulationType: "wash_trading",
confidence: 0.8,
indicators: [
"Circular trading between same addresses",
`${pairTrades.length} trades in ${timeWindowMinutes} minutes`,
],
involvedAddresses: key.split("|"),
volumeAffected: pairTrades.reduce((s, t) => s + t.amount, 0),
priceImpactPct: 0,
status: "detected",
})
}
}
})
return patterns
}