Development of Sanctions Screening System
Sanctions screening is checking customers and counterparties against official sanctions lists. For crypto business this is a two-level task: screening customer identity (OFAC SDN, EU sanctions, UN) and screening crypto addresses (OFAC SDN Crypto Addresses, Chainalysis Sanctioned).
Sanctions Data Sources
OFAC SDN List (USA): most important. Updated several times per week. XML format includes crypto addresses since 2018 (Tornado Cash, OFAC designates). Available free: https://www.treasury.gov/ofac/downloads/SDN_advanced.xml.
EU Consolidated Sanctions: https://webgate.ec.europa.eu/fsd/fsf. Covers all EU sanctions.
UN Security Council: https://scsanctions.un.org/.
UK OFSI: Post-Brexit UK sanctions list.
ComplyAdvantage / Refinitiv: commercial providers aggregating all lists + enrichment.
Parsing OFAC SDN for Crypto Addresses
import { parseStringPromise } from "xml2js";
import axios from "axios";
interface SanctionedCryptoAddress {
address: string;
currency: string; // XBT, ETH, USDT, etc.
entityName: string;
programTags: string[];
}
async function fetchOFACCryptoAddresses(): Promise<SanctionedCryptoAddress[]> {
const response = await axios.get(
"https://www.treasury.gov/ofac/downloads/SDN_advanced.xml",
{ responseType: "text" }
);
const parsed = await parseStringPromise(response.data);
const sdnEntries = parsed.sdnList.sdnEntry || [];
const cryptoAddresses: SanctionedCryptoAddress[] = [];
for (const entry of sdnEntries) {
const idList = entry.idList?.[0]?.id || [];
for (const id of idList) {
const idType = id.idType?.[0];
// OFAC uses "Digital Currency Address - ETH", "Digital Currency Address - XBT" etc.
if (idType?.includes("Digital Currency Address")) {
const currency = idType.split(" - ")[1];
cryptoAddresses.push({
address: id.idNumber?.[0]?.toLowerCase(),
currency,
entityName: `${entry.lastName?.[0]} ${entry.firstName?.[0] || ""}`.trim(),
programTags: (entry.programList?.[0]?.program || []),
});
}
}
}
return cryptoAddresses;
}
Screening System
class SanctionsScreeningService {
private nameIndex: Map<string, SanctionedPerson[]>;
private cryptoAddressSet: Set<string>;
private lastUpdated: Date;
// Update from all sources
async updateLists(): Promise<void> {
const [ofacAddresses, ofacPersons, euSanctions] = await Promise.all([
fetchOFACCryptoAddresses(),
fetchOFACPersons(),
fetchEUSanctions(),
]);
// Rebuild indexes
this.cryptoAddressSet = new Set(ofacAddresses.map(a => a.address.toLowerCase()));
// Fuzzy name index for person screening
this.nameIndex = buildNameIndex([...ofacPersons, ...euSanctions]);
this.lastUpdated = new Date();
await this.cache.set("sanctions_last_updated", this.lastUpdated);
}
// Screen crypto address (exact match)
screenAddress(address: string): AddressScreenResult {
const normalized = address.toLowerCase();
if (this.cryptoAddressSet.has(normalized)) {
return { isSanctioned: true, matchType: "EXACT" };
}
return { isSanctioned: false };
}
// Screen personal data (fuzzy matching)
screenPerson(name: string, dob?: string, country?: string): PersonScreenResult {
const candidates = this.nameIndex.get(normalizeNameKey(name)) || [];
for (const candidate of candidates) {
const score = calculateMatchScore(name, dob, country, candidate);
if (score >= 95) {
return { isSanctioned: true, matchType: "STRONG", matchScore: score, entity: candidate };
}
if (score >= 75) {
return { isSanctioned: false, isPotentialMatch: true, matchScore: score, entity: candidate };
}
}
return { isSanctioned: false };
}
}
Fuzzy Matching for Names
Names translate between alphabets, change (maiden name), are written with errors. Exact string matching gives false negatives:
import Fuse from "fuse.js";
import { transliterate } from "transliteration";
function calculateMatchScore(
inputName: string,
inputDob: string | undefined,
inputCountry: string | undefined,
candidate: SanctionedPerson
): number {
// Transliteration (Іванов → Ivanov)
const normalizedInput = transliterate(inputName.toLowerCase());
const normalizedCandidate = transliterate(candidate.name.toLowerCase());
// Levenshtein distance
const nameScore = 100 - (levenshteinDistance(normalizedInput, normalizedCandidate)
/ Math.max(normalizedInput.length, normalizedCandidate.length)) * 100;
let totalScore = nameScore;
// If DOB matches — boost confidence
if (inputDob && candidate.dob) {
if (inputDob === candidate.dob) totalScore = Math.min(100, totalScore + 20);
else totalScore = Math.max(0, totalScore - 10);
}
// If country matches — small bonus
if (inputCountry && candidate.countries?.includes(inputCountry)) {
totalScore = Math.min(100, totalScore + 5);
}
return totalScore;
}
Continuous Monitoring
Sanctions lists update unexpectedly (emergency designations). Cron job needed for sync:
// Update every 2 hours
@Cron("0 */2 * * *")
async syncSanctionsList() {
await this.sanctionsService.updateLists();
// Re-screen active customers on major updates
const updateSize = await this.detectSignificantUpdate();
if (updateSize > 10) {
await this.rescreenActiveCustomers();
}
}
Sanctions screening system with OFAC/EU parsing, fuzzy name matching and continuous monitoring — 2-3 weeks.







