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: June 29, 2023|Fully applicable: December 30, 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

View POC

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)

View POC

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

View POC

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

View POC

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

View POC

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

View POC

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

View POC

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

View POC

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: December 30, 2024