DEV Community

Cover image for How I Built a Real-Time Compliance Router for Stablecoin Payments Using Cleanverse A-Pass
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

How I Built a Real-Time Compliance Router for Stablecoin Payments Using Cleanverse A-Pass

Most compliance in crypto is bolted on after the fact. A transaction happens, then someone checks if it was okay. By then it's too late.

I wanted to build something different — a system where compliance is checked before the transaction is broadcast, and where the rules are derived from the sender and recipient's actual identity, not from static lookup tables.

That's how ComplianceRouter came together. It's a middleware layer that sits between a payment app and the blockchain. Every payment goes through it. Every payment gets a fresh compliance envelope based on who's sending, who's receiving, and what Cleanverse knows about their identity.

Here's how it works under the hood.


Architecture Overview

┌──────────────────────────────────────────────────────┐
│                    Frontend (React)                   │
│   Landing Page │ Dashboard │ Route Payment │ Audit   │
└──────────────┬───────────────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────────────┐
│                  Express API (port 4000)              │
│                                                      │
│  POST /route ──► JurisdictionResolver                │
│                      │                                │
│                      ├──► Address Registry (SQLite)   │
│                      └──► Cleanverse A-Pass API       │
│                      │                                │
│                      ▼                                │
│                  RuleEngine.deriveEnvelope()          │
│                      │                                │
│                      ▼                                │
│                  AuditLogger ──► SQLite               │
│                                                      │
│  POST /route/:id/execute ──► On-chain USDC transfer  │
└──────────────────────────────────────────────────────┘
               │
               ▼
┌──────────────────────────────────────────────────────┐
│              Monad Testnet (USDC ERC-20)              │
└──────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The key insight: compliance isn't configured — it's derived. When you route a payment, the system queries Cleanverse for the sender's A-Pass, reads their tier, group, and status, and builds the compliance envelope from that.


The Core: Deriving Compliance from A-Pass

The rule engine doesn't look up static corridor rules. It computes everything from Cleanverse A-Pass data at payment time.

Here's the decision flow in src/services/rule-engine.ts:

deriveEnvelope(
  jurisdictionPair: JurisdictionPair,
  amount: number,
  asset: string
): ComplianceEnvelope {
  const { sender, recipient, corridorKey, senderAPass, recipientAPass } = jurisdictionPair;

  // 1. Blacklisted sender → block
  if (senderAPass?.isBlacklisted) {
    return this.blockedEnvelope(corridorKey, 'Sender is blacklisted');
  }

  // 2. Frozen/paused → block
  if (senderAPass?.isPaused || senderAPass?.status === 'FROZEN') {
    return this.blockedEnvelope(corridorKey, 'Sender A-Pass is frozen');
  }

  // 3. Sanctioned jurisdiction → block (both directions)
  if (SANCTIONED_JURISDICTIONS.has(sender.countryCode)) {
    return this.blockedEnvelope(corridorKey, 'Sender is under sanctions');
  }
  if (SANCTIONED_JURISDICTIONS.has(recipient.countryCode)) {
    return this.blockedEnvelope(corridorKey, 'Recipient is under sanctions');
  }

  // 4. Derive from A-Pass tier
  const tier = senderAPass?.tier || 'TIER_3';
  const group = senderAPass?.group || 'CLEANVERSE_USER';

  const amlThreshold = TIER_AML_THRESHOLDS[tier] || 1000;
  const travelFields = TIER_TRAVEL_FIELDS[tier];
  const watchlists = GROUP_WATCHLISTS[group] || ['ofac', 'eu', 'un'];

  // 5. Cross-jurisdiction = Travel Rule required
  const isCrossJurisdiction = sender.countryCode !== recipient.countryCode;

  // 6. Return compliance envelope
  return {
    corridorKey,
    travelRule: { required: isCrossJurisdiction, mandatoryFields: travelFields, ... },
    aml: { screeningRequired: true, thresholdAmount: amlThreshold, ... },
    sanctions: { realTimeScreening: true, watchlists, matchThreshold: 85 },
    reporting: { format: 'fatf_str', retentionDays: 2555, ... },
    restrictions: { blocked: false, allowedAssets: ['USDC', 'USDT'] }
  };
}
Enter fullscreen mode Exit fullscreen mode

The compliance rules are mapped to A-Pass signals:

A-Pass Signal Compliance Effect
tier: TIER_1 $10K AML threshold, 4 Travel Rule fields
tier: TIER_2 $3K AML threshold, 6 Travel Rule fields
tier: TIER_3 $1K AML threshold, 7 Travel Rule fields + EDD
group: FINANCIAL_INSTITUTION Adds fincen to sanctions watchlists
status: FROZEN Block payment
isBlacklisted: true Block payment
Cross-jurisdiction Travel Rule data collection required

Cleanverse API Integration

All Cleanverse calls go through a single HTTP client (src/integrations/cleanverse/client.ts) that handles authentication and encryption:

export async function cleanverseRequest<T>(options: CleanverseRequestOptions) {
  const { endpoint, body = {}, encrypted = false } = options;

  const headers = {
    'Content-Type': 'application/json',
    'api-id': config.cleanverse.apiId  // Required on all requests
  };

  let requestBody: string;
  if (encrypted) {
    // AES-256-CBC encryption for sensitive endpoints
    const encoded = encodePayload(body, config.cleanverse.apiKey);
    requestBody = JSON.stringify({ data: encoded });
  } else {
    requestBody = JSON.stringify(body);
  }

  const response = await fetch(`${config.cleanverse.apiUrl}${endpoint}`, {
    method: 'POST',
    headers,
    body: requestBody
  });

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

The A-Pass query (src/integrations/cleanverse/a-pass.ts) is straightforward:

export async function queryAPass(chain: string, address: string) {
  const response = await cleanverseRequest<APassQueryResult>({
    endpoint: '/query_apass',
    method: 'POST',
    body: { chain, address }
  });

  if (response.code === 4 && response.result) {
    return { success: true, data: response.result };
  }
  return { success: false, error: response.message };
}
Enter fullscreen mode Exit fullscreen mode

Key details:

  • POST /query_apass returns status, tier, group, subGroup, isBlacklisted, isPaused, isRegisted
  • code=4 means A-Pass exists, code=2 means no A-Pass
  • POST /verify_apass is used for A-Token transfer verification
  • Some endpoints require AES encryption (the API key doubles as the encryption key)

Jurisdiction Resolution

The jurisdiction resolver (src/services/jurisdiction-resolver.ts) is a two-layer lookup:

async resolveJurisdiction(chain: string, address: string) {
  // Layer 1: Check local address registry (instant)
  const registryResult = this.addressRegistry.lookup(chain, address);
  if (registryResult) {
    return {
      countryCode: registryResult.countryCode,
      riskTier: registryResult.riskTier,
      source: 'address_registry',
      confidence: 0.9
    };
  }

  // Layer 2: Query Cleanverse A-Pass (network call)
  const apiResult = await queryAPass(chain, address);
  if (apiResult.success && apiResult.data) {
    return {
      countryCode: 'UNKNOWN',  // A-Pass doesn't return country
      riskTier: this.deriveRiskTierFromAPass(apiResult.data),
      source: 'cleanverse_api',
      aPass: apiResult.data
    };
  }

  // Fallback: unknown
  return { countryCode: 'UNKNOWN', riskTier: 'medium', source: 'none' };
}
Enter fullscreen mode Exit fullscreen mode

The resolver is called for both sender and recipient. The results flow into the rule engine as a JurisdictionPair:

interface JurisdictionPair {
  sender: Jurisdiction;
  recipient: Jurisdiction;
  corridorKey: string;        // e.g. "US:DE"
  senderAPass?: APassData;    // Full A-Pass data from Cleanverse
  recipientAPass?: APassData;
}
Enter fullscreen mode Exit fullscreen mode

On-Chain Execution

When a payment is approved, the frontend triggers a real USDC transfer via MetaMask:

// dashboard/src/lib/sendUSDC.ts
export async function sendUSDC(to: string, amount: number) {
  const provider = new BrowserProvider(window.ethereum);
  const signer = await provider.getSigner();

  // USDC contract on Monad testnet
  const USDC_ADDRESS = '0x534b2f3A21130d7a60830c2Df862319e593943A3';
  const ERC20_ABI = ['function transfer(address,uint256) returns (bool)'];

  const usdc = new Contract(USDC_ADDRESS, ERC20_ABI, signer);
  const tx = await usdc.transfer(to, parseUnits(amount.toString(), 6));

  return { txHash: tx.hash };
}
Enter fullscreen mode Exit fullscreen mode

The tx hash is then stored in the audit log via POST /route/:id/execute.


The Dashboard

The frontend is a React 19 app with:

  • Landing page at / — product overview with feature cards
  • Dashboard at /dashboard.html — KPIs, charts, recent transactions
  • Route Payment — select wallets, set amount, see compliance envelope, execute on-chain
  • Address Book — register wallets, auto-detect risk from A-Pass
  • Corridors — view/override compliance rules
  • Audit Log — every decision with compliance hash + tx hash

Dark/light mode via ThemeContext. Wallet connection via EIP-1193 (window.ethereum).


Testing

40 tests across 4 test files:

npx vitest run

✓ tests/api.test.ts           (9 tests)
✓ tests/audit-logger.test.ts  (8 tests)
✓ tests/rule-engine.test.ts   (13 tests)
✓ tests/jurisdiction-resolver.test.ts (9 tests)

Tests: 40 passed
Enter fullscreen mode Exit fullscreen mode

Key test scenarios:

  • A-Pass blacklisted → blocked
  • Sanctioned jurisdiction (both directions) → blocked
  • TIER_1 sender → $10K threshold, lighter compliance
  • TIER_3 sender → $1K threshold, full EDD
  • Cross-jurisdiction → Travel Rule required
  • Amount exceeding tier max → blocked

What I'd Do Next

  1. A-Token verification — verify the asset is a clean A-Token before execution
  2. Multi-chain support — extend beyond Monad to Base, Arbitrum, Polygon
  3. Webhook notifications — alert compliance teams on blocked transactions
  4. CSV/PDF audit export — downloadable reports for regulators
  5. Travel Rule data submission — actually submit Travel Rule data via CCP Protocol

Try It

git clone https://github.com/harishkotra/compliance-router.git
cd compliance-router
npm install && cd dashboard && npm install && cd ..
cp .env.example .env  # Add your Cleanverse API keys
npx tsx src/index.ts   # Backend on :4000
cd dashboard && npm run dev  # Frontend on :5173
Enter fullscreen mode Exit fullscreen mode

Screenshots

CR-1

CR-2

CR-3

CR-4

CR-5

CR-6

Code & more: https://www.dailybuild.xyz/project/167-compliance-router

Top comments (0)