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

ArticleThreshold / Deadline
Art. 4.2Whitepaper exemption: ≤ EUR 1,000,000 over 12 months
Art. 4.2Per-MS exemption: < 150 persons per Member State
Art. 4.2NCA notification: ≥ 20 WORKING DAYS before publication
Art. 9Retail withdrawal period: 14 CALENDAR DAYS
Art. 9Refund deadline: 14 CALENDAR DAYS from notice
Art. 17ART authorization decision: 60 WORKING DAYS
Art. 22ART own funds floor: EUR 350,000
Art. 22ART own funds: 2% of 6-month average reserve
Art. 22Significant ART own funds: 3% of reserve
Art. 23ART overnight deposits: ≥ 60% of reserve
Art. 23ART concentration limit: ≤ 10% per institution
Art. 28ART redemption: 10 WORKING DAYS
Art. 43Significant ART: ≥ 3 of 7 criteria
Art. 43Holders threshold: > 10,000,000
Art. 43Market cap threshold: > EUR 5,000,000,000
Art. 48EMT NCA notification: ≥ 40 WORKING DAYS
Art. 53EMT redemption: 1 BUSINESS DAY at par value
Art. 59CASP authorization: 40 WORKING DAYS
Art. 67CASP Tier 1: EUR 50,000
Art. 67CASP Tier 2: EUR 125,000
Art. 67CASP Tier 3: EUR 150,000
Art. 69Complaint acknowledgment: 5 BUSINESS DAYS
Art. 69Complaint response: 15 BUSINESS DAYS (extendable to 35)
Art. 69Complaint records: 5 YEARS retention

Article-by-Article Mapping

Title II — Crypto-Assets (not ART/EMT) — Art. 4–15

Art. 4

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

Art. 4 — TypeScript / ZodTypeScript
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[]
}
Art. 6

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
Art. 6 — TypeScript / ZodTypeScript
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 }
}
Art. 9

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

Art. 9 — TypeScript / ZodTypeScript
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

Art. 22

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

Art. 22 — TypeScript / ZodTypeScript
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%
  }
}
Art. 23

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

Art. 23 — TypeScript / ZodTypeScript
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. 28-29

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)

Art. 28-29 — TypeScript / ZodTypeScript
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

Art. 53

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)

Art. 53 — TypeScript / ZodTypeScript
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))
}
Art. 55

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

Art. 55 — TypeScript / ZodTypeScript
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

Art. 67

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

Art. 67 — TypeScript / ZodTypeScript
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),
  }
}
Art. 69

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

Art. 69 — TypeScript / ZodTypeScript
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),
  }
}
Art. 72 + 79

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)
Art. 72 + 79 — TypeScript / ZodTypeScript
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

Art. 88

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)
Art. 88 — TypeScript / ZodTypeScript
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",
  }
}
Art. 91

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
Art. 91 — TypeScript / ZodTypeScript
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
}

Regulation (EU) 2023/1114 — Markets in Crypto-Assets Regulation (MiCA)

CELEX:32023R1114 · Records retained 5 years per Art. 75 · Fully applicable from 30 December 2024