Skip to the content.

3. Compliance Model

← Back to index

The protocol’s central pillar: compliance is enforced at every transfer, on every chain, with no escape hatches. This page explains how that works in practice.

3.1 The principle

A regulated RWA token cannot move between two non-KYC’d wallets. Period. This applies to:

If any party in a transfer is not on the allowlist, the call reverts at the token level. There is no bypass at the marketplace level, no “trusted operator” exception, no time-locked grace period.

On CCIP-routed chains, we use the Chainlink Asset Compliance Engine (ACE):

┌────────────────────────┐  per-call hook  ┌──────────────────────────┐
│  RWAToken (ERC-20)     │ ──────────────► │   PolicyEngine (UUPS)    │
│  PolicyProtected       │                 │                          │
└────────────────────────┘                 │   per-(token, selector): │
                                           │     ▶ AllowPolicy        │
                                           │     ▶ PausePolicy        │
                                           └──────────────────────────┘

On every transfer, transferFrom, and mint, the token contract calls policyEngine.run(payload). The engine looks up the registered policies for that (token, selector) pair and runs each. If any policy reverts, the entire transfer reverts.

Default policy set:

Owner-controlled admin:

Where to find allowlist contracts per chain: see Contract Reference.

3.3 Non-CCIP chains — same semantics, simpler stack

Chains reached via Chainlink CRE or future additional routing providers don’t natively run the ACE PolicyEngine. They host a WrappedMBT v2 which inlines the same compliance semantics into a single contract:

function _checkCompliance(address from, address to) internal view {
    if (paused) revert ContractPaused();
    if (from != address(0) && !allowlist[from]) revert NotAllowlisted(from);
    if (to != address(0) && !allowlist[to]) revert NotAllowlisted(to);
}

Called on every state-changing function: mint, burn, transfer, transferFrom. The same allowlist mappings, the same admin functions (allowAddress / disallowAddress / allowAddresses / setPaused).

We chose inlined enforcement over a full PolicyEngine deployment for non-CCIP chains because:

  1. No upgrade key on the contract — non-upgradeable means no proxy admin to compromise
  2. Single contract, ~50 LOC of new compliance code — minimal attack surface
  3. Impossible to misconfigure — the check sits inside _update-equivalent paths, every state change goes through it; there’s no “forgot to attach a policy to a selector” failure mode

The economic and regulatory effect is identical to the full ACE stack.

3.4 KYC mirroring — adding a new investor

Today, when a wallet is KYC-approved, it must be added to the allowlist on every chain the protocol operates on. The cast commands for this batch are in Operations Runbook (internal ops, not public) §3.C.

Future: automatic mirroring

The roadmap is for ACE itself to surface a KYC credential that any chain’s allowlist can read off-chain or via a credential proof. When that ships, the CRE workflows (and any future additional routing providers) can auto-allowlist a recipient at bridge-mint time, eliminating manual mirroring.

Until then, manual mirroring is the rule. Missing it on one chain means the wallet can’t receive bridged tokens there — the bridge mint will revert at _checkCompliance.

3.5 What this gives investors

A wallet that’s been KYC-approved can:

A wallet that has not been KYC-approved cannot interact with the token at all — receive, send, or hold it. Attempts revert. There is no greylist, no probationary period, no “small amount” exception.

3.6 Pause behavior

PausePolicy.setPaused(true) (or WrappedMBT.setPaused(true) on non-CCIP chains) freezes all token movement instantly. Used for:

A paused token is non-transferable. Existing balances are preserved. Reads (balanceOf, etc.) still work.


Next → Cross-Chain Routing