DEV Community

easysolutions906
easysolutions906

Posted on

OFAC Sanctions Screening for Crypto and DeFi: A Developer's Guide

OFAC Sanctions Screening for Crypto and DeFi: A Developer's Guide

If you build on crypto rails, OFAC compliance is not optional. The Treasury Department has made this clear through enforcement actions, guidance documents, and the Tornado Cash precedent. This guide covers what you need to screen, how to screen it, and working code to integrate sanctions screening into a crypto or DeFi application.

Why Crypto Platforms Must Screen

Three things changed the compliance landscape for crypto:

FinCEN guidance (2019). FinCEN clarified that virtual asset service providers (VASPs) -- exchanges, custodial wallet providers, and money transmitters dealing in crypto -- are money services businesses under the Bank Secrecy Act. That means the same OFAC obligations that apply to banks apply to you. If you facilitate a transaction involving a sanctioned person, entity, or wallet address, you are liable.

OFAC's wallet address designations. Starting in 2018, OFAC began adding cryptocurrency wallet addresses directly to the SDN list. These are not just names anymore. A sanctioned entity might have five Bitcoin addresses and three Ethereum addresses listed alongside their traditional identifiers. Your screening must cover both names and addresses.

Tornado Cash (August 2022). OFAC designated Tornado Cash smart contract addresses, marking the first time a decentralized protocol was sanctioned. The message was explicit: interacting with sanctioned addresses -- even through smart contracts -- can violate sanctions. Developers who integrated Tornado Cash had to remove it. Users who interacted with those addresses after designation faced potential liability. Courts have debated the scope, but the enforcement posture is clear.

The bottom line: if your platform touches fiat on-ramps, custodial wallets, KYC/KYB flows, or transaction routing, you screen. If you are building a DEX aggregator or bridge that routes through known sanctioned contracts, you screen. The cost of not screening is asset seizure, fines up to $20 million per violation, and criminal referral.

What to Screen

Crypto platforms need to screen at three points:

  1. Customer onboarding. Screen the customer's name, aliases, and date of birth against the SDN list before account creation. This is identical to traditional finance KYC.

  2. Wallet addresses. Screen deposit and withdrawal addresses against OFAC-designated wallet addresses. The SDN list includes addresses for Bitcoin, Ethereum, Litecoin, XRP, and others under the "Digital Currency Address" identifier type.

  3. Transaction counterparties. For each transaction, screen the counterparty name and wallet address. If a user sends funds to or receives funds from a sanctioned address, you need to catch it.

Screening Names Against the SDN List

The @easysolutions906/mcp-ofac package embeds the full SDN list and provides fuzzy matching out of the box. Here is how to screen a customer name during onboarding:

const OFAC_BASE_URL = 'https://ofac-screening-production.up.railway.app';
const OFAC_API_KEY = process.env.OFAC_API_KEY;

const screenCustomer = async (name, options = {}) => {
  const {
    type = 'Individual',
    country = null,
    dateOfBirth = null,
    threshold = 0.85,
  } = options;

  const body = { name, type, threshold };
  if (country) { body.country = country; }
  if (dateOfBirth) { body.dateOfBirth = dateOfBirth; }

  const response = await fetch(`${OFAC_BASE_URL}/screen`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': OFAC_API_KEY,
    },
    body: JSON.stringify(body),
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(
      `OFAC screening failed: ${response.status} ${error.error || response.statusText}`,
    );
  }

  return response.json();
};
Enter fullscreen mode Exit fullscreen mode

Usage in a crypto exchange onboarding flow:

const onboardCryptoUser = async (user) => {
  const screening = await screenCustomer(user.fullName, {
    type: 'Individual',
    country: user.country,
    dateOfBirth: user.dob,
  });

  if (screening.matchCount > 0) {
    const topScore = screening.matches[0].score;

    if (topScore >= 0.95) {
      // Near-certain match -- block account creation, escalate
      return { status: 'BLOCKED', reason: 'SDN match', screening };
    }

    // Possible match -- allow provisional access, flag for review
    return { status: 'PENDING_REVIEW', screening };
  }

  return { status: 'CLEAR', screening };
};
Enter fullscreen mode Exit fullscreen mode

Screening Wallet Addresses

Wallet addresses on the SDN list are exact strings. There is no fuzzy matching for addresses -- a Bitcoin address either matches or it does not. But you still need to normalize them (case-insensitive for Ethereum, exact for Bitcoin) and check against the full set of designated addresses.

The OFAC API search endpoint lets you query by identifier type:

const screenWalletAddress = async (address, currency = 'ETH') => {
  const response = await fetch(`${OFAC_BASE_URL}/search`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': OFAC_API_KEY,
    },
    body: JSON.stringify({
      query: address,
      idType: 'Digital Currency Address',
    }),
  });

  if (!response.ok) {
    throw new Error(`Wallet screening failed: ${response.status}`);
  }

  const data = await response.json();

  return {
    address,
    currency,
    isSanctioned: data.results.length > 0,
    matches: data.results,
    screenedAt: data.screenedAt,
    listVersion: data.listVersion,
  };
};
Enter fullscreen mode Exit fullscreen mode

Integrate this into your deposit and withdrawal flows:

const processWithdrawal = async (userId, toAddress, amount, currency) => {
  // Screen the destination address before sending
  const walletScreen = await screenWalletAddress(toAddress, currency);

  if (walletScreen.isSanctioned) {
    await logBlockedTransaction(userId, toAddress, amount, walletScreen);
    await notifyComplianceTeam({
      type: 'BLOCKED_WITHDRAWAL',
      userId,
      address: toAddress,
      matches: walletScreen.matches,
    });
    throw new Error('Transaction blocked: sanctioned address');
  }

  // Address is clear -- proceed with withdrawal
  await recordScreeningResult(userId, walletScreen);
  return executeWithdrawal(userId, toAddress, amount, currency);
};

const processDeposit = async (userId, fromAddress, amount, currency) => {
  // Screen the source address after receiving
  const walletScreen = await screenWalletAddress(fromAddress, currency);

  if (walletScreen.isSanctioned) {
    // Freeze the deposited funds and alert compliance
    await freezeFunds(userId, amount, currency);
    await notifyComplianceTeam({
      type: 'SANCTIONED_DEPOSIT',
      userId,
      address: fromAddress,
      amount,
      matches: walletScreen.matches,
    });
    return { status: 'FROZEN', screening: walletScreen };
  }

  await recordScreeningResult(userId, walletScreen);
  return { status: 'CREDITED', screening: walletScreen };
};
Enter fullscreen mode Exit fullscreen mode

Key difference from traditional finance: for withdrawals you block before sending. For deposits you freeze after receiving, because you cannot prevent someone from sending crypto to your address. Both events require logging and compliance notification.

Batch Screening for Onboarding Flows

When you first integrate OFAC screening into an existing platform, you need to screen your entire user base and every stored wallet address. The batch endpoint handles up to 100 names per request:

const batchScreenUsers = async (users) => {
  const BATCH_SIZE = 100;
  const flagged = [];

  for (let i = 0; i < users.length; i += BATCH_SIZE) {
    const batch = users.slice(i, i + BATCH_SIZE);
    const names = batch.map((u) => ({
      name: u.fullName,
      type: 'Individual',
      country: u.country || null,
    }));

    const response = await fetch(`${OFAC_BASE_URL}/screen/batch`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'x-api-key': OFAC_API_KEY,
      },
      body: JSON.stringify({ names, threshold: 0.85 }),
    });

    const data = await response.json();

    data.results.forEach((result, idx) => {
      if (result.matchCount > 0) {
        flagged.push({
          user: batch[idx],
          matches: result.matches,
          listVersion: data.listVersion,
          screenedAt: data.screenedAt,
        });
      }
    });

    console.log(
      `Screened ${Math.min(i + BATCH_SIZE, users.length)}/${users.length} users`,
    );
  }

  return flagged;
};
Enter fullscreen mode Exit fullscreen mode

For wallet addresses, screen them separately since address matching is exact and does not use the same fuzzy logic:

const batchScreenAddresses = async (addresses) => {
  const flagged = [];

  // Wallet addresses must be screened individually since they are
  // identifier lookups, not fuzzy name matches
  const screenPromises = addresses.map(async ({ address, currency, userId }) => {
    const result = await screenWalletAddress(address, currency);
    if (result.isSanctioned) {
      flagged.push({ userId, address, currency, ...result });
    }
  });

  // Process in controlled concurrency to avoid rate limits
  const CONCURRENCY = 10;
  for (let i = 0; i < screenPromises.length; i += CONCURRENCY) {
    await Promise.all(screenPromises.slice(i, i + CONCURRENCY));
  }

  return flagged;
};
Enter fullscreen mode Exit fullscreen mode

Keeping Up with SDN List Updates

OFAC updates the SDN list multiple times per week. New wallet addresses get added when Treasury designates crypto-linked actors. Your screening is only as good as the list version you are checking against.

Set up a daily job that checks for list updates and triggers re-screening:

let lastKnownListVersion = null;

const checkForSDNUpdate = async () => {
  const response = await fetch(`${OFAC_BASE_URL}/data-info`);
  const info = await response.json();

  if (lastKnownListVersion && info.publishDate !== lastKnownListVersion) {
    console.log(
      `SDN list updated: ${lastKnownListVersion} -> ${info.publishDate}`,
    );
    return { updated: true, newVersion: info.publishDate };
  }

  lastKnownListVersion = info.publishDate;
  return { updated: false, currentVersion: info.publishDate };
};

const dailyComplianceScan = async () => {
  const { updated, newVersion } = await checkForSDNUpdate();

  if (!updated) {
    console.log('SDN list unchanged, skipping re-screen');
    return;
  }

  // Re-screen all active users
  const users = await loadAllActiveUsers();
  const flaggedUsers = await batchScreenUsers(users);

  // Re-screen all stored wallet addresses
  const addresses = await loadAllStoredAddresses();
  const flaggedAddresses = await batchScreenAddresses(addresses);

  if (flaggedUsers.length > 0 || flaggedAddresses.length > 0) {
    await notifyComplianceTeam({
      listVersion: newVersion,
      flaggedUsers,
      flaggedAddresses,
      totalUsersScreened: users.length,
      totalAddressesScreened: addresses.length,
    });
  }

  console.log(
    `Re-screen complete. ${flaggedUsers.length} users flagged, `
    + `${flaggedAddresses.length} addresses flagged.`,
  );
};
Enter fullscreen mode Exit fullscreen mode

In production, run this with a proper job scheduler (cron, Bull, or your cloud provider's scheduled tasks). For high-risk platforms -- those dealing in privacy coins or cross-chain bridges -- run it every six hours instead of daily.

Storing Screening Records

Every screening result must be stored for at least five years per BSA requirements. For crypto platforms, the audit record needs both name and address screening data:

const createAuditRecord = (userId, screeningType, result) => ({
  userId,
  screeningType,       // 'NAME', 'WALLET_ADDRESS', or 'TRANSACTION'
  screenedValue: result.name || result.address,
  screenedAt: result.screenedAt,
  listVersion: result.listVersion,
  matchCount: result.matchCount || (result.isSanctioned ? 1 : 0),
  decision: result.matchCount > 0 || result.isSanctioned ? 'FLAGGED' : 'CLEAR',
  matches: result.matches || [],
  reviewedBy: null,
  reviewedAt: null,
  reviewDecision: null,
});
Enter fullscreen mode Exit fullscreen mode

Store these in an append-only table. No UPDATE or DELETE permissions for application users. When a FinCEN examiner or state regulator asks for your screening records for a specific user or date range, you need to produce them instantly.

Using the MCP Server for Manual Reviews

For compliance officers who need to investigate flagged transactions manually, the MCP server integrates directly into Claude Desktop or Cursor:

{
  "mcpServers": {
    "ofac": {
      "command": "npx",
      "args": ["-y", "@easysolutions906/mcp-ofac"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

A compliance officer can then ask Claude:

Screen the name "Lazarus Group" and check if Ethereum address 0x098B716B8Aaf21512996dC57EB0615e2383E2f96 appears on the SDN list.

Claude calls the appropriate tools and returns formatted results inline. This is faster than switching between a screening portal and case management system, and the conversation log serves as a reviewable audit trail.

Architecture Recap

A production-ready crypto compliance pipeline has four layers:

  1. Real-time screening. Every customer onboarding, every deposit address, every withdrawal address gets screened synchronously. Block or freeze if a match is found.

  2. Batch re-screening. When the SDN list updates, re-screen your entire user and address database. New designations can flag previously clear customers.

  3. Audit storage. Every screening result is stored immutably with the list version and timestamp. Retention is five years minimum.

  4. Human review. Matches are never auto-rejected without review. A compliance officer evaluates flagged results and documents their decision.

The API handles layers one and two. Your database handles layer three. Your compliance team handles layer four.

Getting Started

  1. Get an API key at ofac-screening-production.up.railway.app
  2. Add name screening to your KYC onboarding flow
  3. Add wallet address screening to your deposit and withdrawal handlers
  4. Set up daily re-screening with the /data-info endpoint to detect list updates
  5. Build the audit trail -- store every result with screenedAt and listVersion

The OFAC API documentation is available at the base URL. The /data-info endpoint returns the current SDN list publish date and record count so you always know how current your screening data is.

Crypto moves fast. OFAC enforcement moves faster. Screen everything, store everything, review everything.

Top comments (0)