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) │
└──────────────────────────────────────────────────────┘
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'] }
};
}
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();
}
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 };
}
Key details:
-
POST /query_apassreturnsstatus,tier,group,subGroup,isBlacklisted,isPaused,isRegisted -
code=4means A-Pass exists,code=2means no A-Pass -
POST /verify_apassis 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' };
}
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;
}
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 };
}
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
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
- A-Token verification — verify the asset is a clean A-Token before execution
- Multi-chain support — extend beyond Monad to Base, Arbitrum, Polygon
- Webhook notifications — alert compliance teams on blocked transactions
- CSV/PDF audit export — downloadable reports for regulators
- 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
Screenshots
Code & more: https://www.dailybuild.xyz/project/167-compliance-router






Top comments (0)