This project integrates a CMTAT security token with Chainlink ACE so that the token's compliance rules are defined in on-chain policies, which the policy engine evaluates on each protected operation. An issuer can adjust who can transact and under which conditions (KYC/allowlists, sanctions screening, transfer and volume limits, trading-hours windows, pause, reserve-backed minting) by reconfiguring those policies, rather than redeploying or modifying the token's business logic.
Regulated tokens (security tokens, real-world assets (RWA), and stablecoins) need to enforce compliance rules such as eligibility, limits, freezes, and pauses, and those rules change over time as regulation, jurisdictions, or counterparties evolve. When the rules are embedded in the token, each change requires a contract upgrade or redeploy. This integration keeps the rules in a separate policy engine, so updating compliance is a configuration change rather than a code change.
- CMTAT (CMTA Token) — an open security-token framework from the Capital Markets and Technology Association. It provides the ERC-20 token plus compliance modules: conditional transfers, account freeze / enforcement (ERC-7943), forced transfer & recovery, pause, in-contract documents, cross-chain mint/burn, and lifecycle controls.
- Chainlink ACE (Automated Compliance Engine) — a
PolicyEnginethat, for a protected function call, runs a configurable chain of policies (small contracts that approve or reject based on the call's parameters) and returns a decision. Policies are added, removed, and reordered by governance at runtime.
- A token function (e.g.
transfer,mint) is protected: before it takes effect, the token asks the PolicyEngine to evaluate the call. - An extractor decodes the call's calldata into named parameters (
from,to,amount, …). - The PolicyEngine runs the policies attached to that function's selector (pause, role-based access, sanctions/allowlist screening, volume/rate limits, reserve checks, and so on). If any policy rejects, the call reverts; otherwise it proceeds.
- To change compliance behavior, governance attaches/detaches/reorders policies; no token redeploy is needed.
Two ready-to-deploy variants (standalone and upgradeable proxies), so an issuer can choose how much of compliance to externalize:
- Lite — keeps CMTAT's native role-based access control and uses ACE only for transfer validation (it replaces CMTAT's RuleEngine). Closest to a standard CMTAT token.
- Standard — policy-authoritative: ACE gates all state-changing operations (mint, burn, transfer, enforcement, admin) instead of local
onlyRolechecks; access control itself becomes a policy concern.
Compliance itself is expressed with policies from the Chainlink ACE policy library (for example pause, role-based access control, volume / rate / interval limits, and reserve-backed Proof-of-Reserve minting) that the issuer attaches to the token and configures. On top of those, this repo adds the glue needed to use them with CMTAT: a custom TransferValidationPolicy that reuses CMTAT's existing IRule transfer rules (KYC/sanctions/allowlist) as ACE policies, the extractors that map each token function's calldata to policy parameters, a complete deployment demo, and a deployment preflight check that catches common misconfigurations before going live.
Issuers of security tokens, real-world assets (RWA), and stablecoins (and their integrators) who want CMTAT's token feature set with compliance that can evolve through governance-controlled policy configuration rather than contract upgrades. (For example, a stablecoin can gate issuance with the reserve-backed SecureMintPolicy and screen holders with sanctions policies, while an RWA fund can enforce eligibility, transfer limits, and trading-hours windows.)
The sequence below shows a transfer being evaluated by the PolicyEngine against a freeze policy: the token forwards the call to the engine, an extractor decodes the parameters, and the policy rejects the transfer if the sender (or recipient) is frozen — otherwise the transfer proceeds and balances are updated.
Diagram source: doc/img/transfer-freeze-workflow.puml.
defaultPolicyAllow = true(shown in the diagram) is the engine's verdict for a call once its policies have run: ACE evaluates the attached policies and, if none of them explicitly returnedAllowed, it falls back to this default. The policies shipped with this integration (pause, RBAC, transfer-validation, freeze, reserve-backed mint) reject by reverting and otherwise returnContinue— they never returnAllowed— so a call is permitted exactly when no policy reverted and the default istrue(allow-by-default; reject only on an explicit policy revert). WithdefaultPolicyAllow = false, the same non-reverting chain would instead be rejected unless a terminal allow policy is attached. See Security Considerations.
SecureMintPolicy gates the mint selector against a Chainlink Proof-of-Reserve (PoR) feed: before issuing new tokens it reads the latest on-chain reserve value and rejects the mint if it would push totalSupply beyond the reserves (optionally adjusted by a configured margin), or if the feed is stale/negative. This enforces that the token can never be minted beyond what is backed by reserves — the reserve-backed issuance pattern used by stablecoins and RWAs.
Diagram source: doc/img/securemint-por-workflow.puml.
- Deployment versions
- Changes from CMTAT
- Compliance Policies
- TransferValidationPolicy
- ERC-165 Interface Support
- Library
- Initialize submodules
- Install dependencies
- Compile contracts
- Testing
- Linting & Formatting
- Scripts
- Deployment Guide
- Policy preflight check
- Audit Reports Summary
- Policy-Protected Functions (Current Integration)
- Security Considerations
- FAQ for Issuers Using CMTAT with ACE Policies
Two versions are available:
- Lite: substitutes RuleEngine with Chainlink ACE PolicyEngine for transfer validation, while keeping CMTAT role-based module authorization.
- Standard: uses Chainlink ACE PolicyEngine as the authorization/compliance gate for state-changing operations, replacing local role-based authorization with policy checks.
Replaces CMTAT's AccessControlUpgradeable (role-based) with OwnableUpgradeable (single owner) and integrates Chainlink ACE PolicyProtectedBaseUpgradeable for access control and compliance validation on state-changing operations (mint, burn, transfer, enforcement, admin functions).
| Contract | Proxy type |
|---|---|
ComplianceTokenCMTATStandalone |
None |
ComplianceTokenCMTATUpgradeable |
Transparent |
ComplianceTokenCMTATUUPSUpgradeable |
UUPS (onlyOwner) |
Keeps CMTAT's AccessControlUpgradeable (role-based) for module authorization and adds Chainlink ACE PolicyEngine for transfer validation only, replacing CMTAT's RuleEngine.
| Contract | Proxy type |
|---|---|
ComplianceTokenCMTATLiteStandalone |
None |
ComplianceTokenCMTATLiteUpgradeable |
Transparent |
ComplianceTokenCMTATLiteUUPSUpgradeable |
UUPS (onlyRole(PROXY_UPGRADE_ROLE)) |
In the Standard variant, critical operations are authorized through ACE runPolicy checks instead of local onlyRole(...) checks. This includes core actions such as mint, burn functions, forced transfer/enforcement actions, and sensitive admin/configuration operations.
This means PolicyEngine configuration is security-critical infrastructure. A bad config change can unintentionally allow or block sensitive actions. It also introduces a direct runtime dependency on Chainlink ACE contracts (PolicyEngine, attached policies, extractor/mapper configuration): if ACE contracts are unavailable, misconfigured, or incorrectly upgraded, authorization and compliance checks in the token are directly affected. For runPolicy context handling, cleanup is best-effort on success only: context is cleared after the guarded function completes successfully. If the guarded call reverts, cleanup is not reached, and previously stored context remains in storage.
Treat the following as privileged governance actions:
addPolicy/removePolicysetExtractor/setPolicyMappersetDefaultAllowattachPolicyEngine
| Aspect | CMTAT | Standard | Lite |
|---|---|---|---|
| Base model | AccessControlUpgradeable with 9+ roles |
OwnableUpgradeable (single owner) |
AccessControlUpgradeable (unchanged) |
| Authorization | onlyRole(MINTER_ROLE), etc. |
runPolicy modifier via PolicyEngine |
onlyRole() for modules, PolicyEngine for transfers |
| Role management | grantRole() / revokeRole() |
Managed externally via RoleBasedAccessControlPolicy |
CMTAT roles preserved |
The Lite variant keeps CMTAT's native AccessControlUpgradeable roles. The table below lists each role and what it authorizes (verified against the module authorization hooks). All roles are managed by DEFAULT_ADMIN_ROLE via grantRole / revokeRole.
| Role | Authorizes |
|---|---|
DEFAULT_ADMIN_ROLE |
Grant/revoke all roles; attach/detach the PolicyEngine (attachPolicyEngine, incl. address(0)); deactivateContract; forcedTransfer; ERC-20 attribute management (name/symbol/decimals); set the CCIP admin |
MINTER_ROLE |
mint |
BURNER_ROLE |
burn |
BURNER_FROM_ROLE |
Cross-chain burn from a holder (crosschainBurn) |
BURNER_SELF_ROLE |
Cross-chain self-burn |
CROSS_CHAIN_ROLE |
Act as the token bridge for cross-chain mint/burn (_checkTokenBridge) |
PAUSER_ROLE |
pause / unpause |
ENFORCER_ROLE |
Freeze/unfreeze addresses (address-level enforcement) |
ERC20ENFORCER_ROLE |
Token-level freeze: setFrozenTokens, freezePartialTokens, unfreezePartialTokens |
DOCUMENT_ROLE |
Manage ERC-1643 documents (setDocument / removeDocument) |
EXTRA_INFORMATION_ROLE |
Set tokenId / terms / information |
PROXY_UPGRADE_ROLE |
Authorize UUPS upgrades (UUPS variant only) |
The Standard variant does not use these roles for module access: it is OwnableUpgradeable (single owner) and routes authorization through the PolicyEngine, where role-like permissions are configured externally via RoleBasedAccessControlPolicy rather than grantRole.
| Aspect | CMTAT | Standard | Lite |
|---|---|---|---|
| Validation layer | CMTATBaseRuleEngine → ValidationModuleRuleEngine |
PolicyProtectedBaseUpgradeable → IPolicyEngine |
ValidationModulePolicyEngine → IPolicyEngine |
| Engine type | RuleEngine (custom interface) | Chainlink ACE PolicyEngine | Chainlink ACE PolicyEngine |
| Transfer check | _canTransferGenericByModuleAndRevert() + RuleEngine |
PolicyEngine run() via runPolicy modifier |
_canTransferGenericByModuleAndRevert() + PolicyEngine run() |
| ERC-1404 support | Via ValidationModuleERC1404 |
Not applicable (no module-level checks) | Via PolicyValidationModuleERC1404 |
In the Lite variant the ERC-1404 view is PolicyEngine-aware: after the module checks (pause/deactivate/freeze/active-balance) pass, detectTransferRestriction / detectTransferRestrictionFrom consult the PolicyEngine and return restriction code 7 (TRANSFER_REJECTED_BY_POLICY_ENGINE_CODE, message "PolicyEngine:transferRejected") when the engine would reject the transfer. Module-level codes take precedence, and the view never reverts.
The PolicyEngine can be detached on the Lite variant but not on the Standard variant. On Lite, an admin (DEFAULT_ADMIN_ROLE) may call attachPolicyEngine(address(0)): access control is CMTAT role-based and the engine is used for transfer validation only, so detaching simply disables ACE policy validation while CMTAT's native validation (pause, enforcement, allowlist, ...) stays in force. On Standard, detaching is rejected (attachPolicyEngine(address(0)) reverts): authorization is policy-authoritative (every privileged operation is runPolicy-gated), so a zero engine would brick the token. This is enforced in _validatePolicyEngine, which keeps the non-zero requirement on Standard and relaxes it on Lite.
The Engine struct parameter is replaced with a single address policyEngine_:
// CMTAT
constructor(forwarder, admin, ..., ICMTATConstructor.Engine memory engines_)
// ComplianceTokenCMTAT (Standard & Lite)
constructor(admin, ..., address policyEngine_)Document management is handled in-contract via CMTAT's DocumentERC1643Module (no external document engine), and the snapshot engine has been removed from this integration, so neither a documentEngine_ nor a snapshotEngine_ parameter is taken.
ERC-2771 (gasless transaction forwarding) has been removed from all deployment contracts. The standalone contracts no longer take a forwarderIrrevocable parameter, and the upgradeable contracts have parameterless constructors.
All CMTAT functional modules are preserved in both variants:
- ERC20MintModule, ERC20BurnModule
- ERC20EnforcementModule (freeze/enforcement)
- PauseModule (Standard:
pause()/unpause()/deactivateContract()are not exposed on the token; pausing is enforced externally via a PausePolicy on the PolicyEngine which rejects operations when paused; Lite: nativeonlyRole(PAUSER_ROLE)) - DocumentERC1643Module (in-contract ERC-1643 document management,
DOCUMENT_ROLE) - ExtraInformationModule
- ERC20CrossChainModule, CCIPModule
The external
DocumentEngineModuleand theSnapshotEngineModulefrom CMTAT are not used in this integration: documents are managed in-contract viaDocumentERC1643Module, and snapshot support has been removed.
CMTATBaseAccessControl— replaced byOwnableUpgradeableAccessControlModule— role management removed from contractCMTATBaseRuleEngine— replaced byPolicyProtectedBaseUpgradeableValidationModuleRuleEngine— replaced by direct PolicyEngine calls- All
onlyRole()authorization functions — replaced byrunPolicymodifier pause(),unpause(),deactivateContract()— not exposed on the token contract; the_authorizePauseand_authorizeDeactivatehooks are intentionally left unimplemented so these functions remain abstract and are excluded from the compiled contract. Pausing is enforced externally via a PausePolicy attached to the PolicyEngine, which rejects protected operations when paused
approve() is intentionally not gated by runPolicy in either variant. An approval by itself does not move tokens; it only sets an allowance. The actual token movement happens via transferFrom(), which is policy-protected. Protecting approve() would add gas overhead without security benefit, since:
- A malicious or excessive approval has no effect until
transferFrom()is called, at which point the PolicyEngine validates the transfer. - The
ERC20TransferFromExtractorextracts thespenderaddress fromtransferFrom()calls, so policies can restrict which spenders are allowed to move tokens regardless of existing approvals. - In the Lite variant,
approve()is gated bywhenNotPausedas a convenience (matching upstream CMTAT behavior), but this is not a security-critical check.
SecureMintPolicy enforces mintAmount + totalSupply() <= reserves, where totalSupply() is the per-chain supply of the token contract it is attached to. This is correct for a single-chain token, but is a footgun for a cross-chain / bridgeable token (ERC20CrossChainModule / crosschainMint):
- A
crosschainMinton chain B mints tokens that were burned on chain A, so global supply does not increase, only chain B's local supply does. - If the Proof-of-Reserve feed reports the global reserves backing the global supply, but the policy compares them against chain B's local
totalSupply(), then as chain B's local supply approaches the global reserve value, legitimate cross-chain mints will be rejected ("mint would exceed available reserves") even though the bridged tokens are fully backed. - Conversely, applying a per-chain reserve value against each chain independently can permit over-minting of the global supply.
Guidance for issuers:
- The Proof-of-Reserve must validate the whole multi-chain supply against the whole reserve, not a single chain in isolation. Use a PoR feed that reports global reserves and a supply accounting that aggregates supply across all chains (e.g. a cross-chain aggregator / CCIP-based PoR), or do not gate
crosschainMintwith a per-chainSecureMintPolicy. - The demo intentionally wires
SecureMintPolicytomint()only (genuine new issuance), not tocrosschainMint(). Do not attach a naive per-chainSecureMintPolicytocrosschainMintunless your PoR design accounts for cross-chain supply as described above.
ERC2771Module— gasless transaction forwarding is not supported (ACE does not currently support ERC-2771)
PolicyProtectedBaseUpgradeable— Chainlink ACE integration with ERC-7201 storage,runPolicymodifier, and policy engine lifecycle managementValidationModulePolicyEngine(Lite) — hybrid validation combining CMTAT module checks with PolicyEnginePolicyValidationModuleERC1404(Lite) — ERC-1404 transfer restriction codes with PolicyEngine awarenessTransferValidationPolicy— Chainlink ACE policy that validates transfers using CMTAT'sIRuleinterface (see TransferValidationPolicy below)ERC20TransferFromExtractor— Extractor that produces 4 parameters (spender,from,to,amount) fortransfer()andtransferFrom()CrossChainMintBurnExtractor— Extractor that mapscrosschainMint/crosschainBurninto the[from, to, amount]layout so the sameIRulerules can screen cross-chain issuance/redemptionCCTVersionModule— overrides CMTAT'sVersionModuleso the token'sversion()returns the CMTAT-ACE integration release (currently0.3.0) instead of the underlying CMTAT framework version. Theversion()view follows the ERC-3643 and ERC-8303 (Contract Version) convention; seedoc/ERCSpecification/erc-8303.md
Compliance behavior is expressed as policies attached to the PolicyEngine per (token, function-selector) pair. When a protected function runs, the engine evaluates the policies registered for that selector, feeding each one the parameters produced by the extractor configured for that selector. A policy either lets evaluation continue, short-circuits to "allow", or reverts to reject the call.
This repository ships one custom policy (TransferValidationPolicy) and reuses the policy library from @chainlink/ace. The most useful policies for a token issuer are summarized below; each row links to the integration test that demonstrates it against a ComplianceToken.
| Policy | What it enforces | run parameters (via extractor) |
Example use case |
|---|---|---|---|
TransferValidationPolicy (custom) |
Runs an array of CMTAT IRule contracts; rejects on any non-zero restriction code |
[from,to,amount] or [spender,from,to,amount] |
Reuse existing CMTAT transfer-restriction rules (sanctions/KYC/max-amount) as ACE policies; see TransferValidationPolicy. Tests: test/custom/transferValidationPolicy.test.js, crosschainScreening.test.js, mintBurnScreening.test.js |
PausePolicy |
Rejects protected calls while paused | none | Emergency pause of mint/burn/transfer without a pause role on the token (Standard variant). Tests: test/common/ace/PausePolicyCommon.js |
RoleBasedAccessControlPolicy |
Caller must hold the role mapped to the selector | none (uses caller + selector) | Externalized role management for admin/lifecycle operations (Standard variant). Tests: test/common/ace/RBACPolicyCommon.js |
SecureMintPolicy |
mintAmount + totalSupply() <= reserves from a Chainlink PoR feed |
[amount] |
Reserve-backed (Proof-of-Reserve) minting. Tests: test/custom/secureMintPolicy.test.js. See the cross-chain PoR caveat |
MaxPolicy |
Per-call hard cap (non-accumulating) | [amount] |
Maximum amount per single transfer/mint. Tests: test/custom/maxPolicy.test.js |
VolumePolicy |
Per-call min <= amount <= max |
[amount] |
Minimum and maximum ticket size per operation. Tests: test/custom/volumePolicy.test.js |
VolumeRatePolicy |
Per-account cumulative cap within a rolling time window | [amount, account] |
Rate-limit how much each holder can move per day/hour. Tests: test/custom/volumeRatePolicy.test.js |
IntervalPolicy |
Execution allowed only within a time window of a repeating cycle | none | Trading-hours / settlement-window restriction. Tests: test/custom/intervalPolicy.test.js |
OnlyOwnerPolicy |
Caller must be the policy's owner | none (uses caller) | Funnel a sensitive function (e.g. mint) through a single governance key, layered on top of CMTAT roles. Tests: test/custom/onlyOwnerPolicy.test.js |
These are part of the installed @chainlink/ace policy library and can be wired the same way, but are not currently configured or tested in this repository:
| Policy | What it enforces |
|---|---|
BypassPolicy |
If an extracted address is on a bypass list, returns Allowed (short-circuits evaluation to allow). The only bundled policy that returns a terminal allow; required if you operate with defaultAllow = false (see below) |
AllowPolicy |
Allow-list: rejects unless the extracted address is on the list |
RejectPolicy |
Block-list: rejects if the extracted address is on the list |
OnlyAuthorizedSenderPolicy |
Rejects unless the caller is on an authorized-sender list |
OnlySubjectOwnerPolicy |
Caller must be the Ownable owner of the token (subject); fits the Standard variant's OwnableUpgradeable |
CertifiedActionValidatorPolicy / …DONValidatorPolicy / …ERC20TransferValidatorPolicy |
Validate off-chain "certified actions" (e.g. DON-signed approvals) before allowing an operation; advanced, for attestation-gated flows |
- Per-selector: a policy attached to
transferdoes not apply tomint,forcedTransfer, orcrosschainMint. Attach screening to every movement selector you care about. See Policy-Protected Functions and theMintBurnExtractor/CrossChainMintBurnExtractorextractors. - Allow-by-default model: nearly all of the policies above return
Continueon success and only revert to reject; none of them return a terminalAllowedexceptBypassPolicy. With the PolicyEngine'sdefaultAllow = true, a call is allowed unless some policy reverts. WithdefaultAllow = false, a call is rejected unless some policy returnsAllowed, so a fail-closed deployment requiresBypassPolicy(or a custom terminal-allow policy) on every protected selector, otherwise operations are bricked. Use the policy preflight check to verify this before going live. - Ordering: policies execute in attachment order; the first
Allowedshort-circuits, any revert rejects. Put fail-closed/restrictive checks before any bypass.
TransferValidationPolicy is a Chainlink ACE policy that bridges CMTAT's IRule interface with the PolicyEngine, enabling reuse of existing transfer restriction rules as ACE policies.
The policy accepts an array of IRule contracts. When the PolicyEngine invokes the policy during a transfer() or transferFrom(), each rule is evaluated in order. If any rule returns a non-zero restriction code, the policy reverts with PolicyRejected containing the rule's human-readable message.
It supports two extractor layouts:
| Extractor | Parameters | Used by |
|---|---|---|
ERC20TransferExtractor |
[from, to, amount] |
Calls detectTransferRestriction(from, to, amount) |
ERC20TransferFromExtractor |
[spender, from, to, amount] |
Calls detectTransferRestrictionFrom(spender, from, to, amount) |
CMTAT's native flow calls only IRule.transferred(...) on each rule during a transfer (enforcement and any state update happen there); detectTransferRestriction* is the read-only preview. ACE cannot fuse the two, because the engine reuses the policy's run() for both the read-only preview (check(), which is STATICCALLed by canTransfer / ERC-1404 detectTransferRestriction / off-chain simulations) and the state-flow pre-check. A STATICCALL forbids state writes, so run() must be view. TransferValidationPolicy therefore splits the work:
| Hook | When the PolicyEngine calls it | What it does |
|---|---|---|
run (view) |
on every check() and every run() (state) |
validates with the view detectTransferRestriction*; reverts PolicyRejected if any rule returns a non-zero code |
postRun (state) |
only after a successful run on the state path (run(payload)); never on check() |
calls each rule's state-mutating transferred(...) hook — this advances stateful rules (rolling-window volume caps, per-period counters, conditional rules) and applies their on-chain enforcement |
Consequences:
- The view check in
runis required, not redundant. Without it,canTransfer/detectTransferRestrictionwould only run a no-op preview and report a transfer as allowed even when it would revert — breaking the ERC-7943 / ERC-1404 view contract and any wallet/AMM/custodian that simulates viacheck(). - Stateful rules are enforced via
postRun/transferred, exactly mirroring CMTAT's write path (RuleEngine.transferred→rule.transferredper rule).postRunruns once per executed transfer, never on a preview, so acanTransfersimulation has no side effects. - For correct behavior a rule's
detectTransferRestriction*andtransferredshould be consistent (the CMTA Rules library rules are):runthen provides an accurate preview/early veto andpostRunthe authoritative state-path enforcement. The integration applies both, so it is never weaker than CMTAT and works whether a rule's logic lives indetect*, intransferred, or in both.
Mock IRule implementations are provided in contracts/modules/chainlink-ace/mocks/TransferRuleMocks.sol for testing and demonstration:
MaxAmountRule— Rejects transfers where the amount exceeds a configurable maximum (restriction code13). Stateless.RestrictedAddressRule— Rejects transfers involving addresses on a configurable restricted list (codes14/15for sender/recipient). Stateless.CumulativeCapRule— Stateful: caps the cumulative amount each sender may send; thesent[from]counter is advanced bytransferred(state path) and read bydetect*. Demonstrates that stateful enforcement requirespostRun/transferred(the NM-2 path).TransferredEnforcedCapRule— Stateful:detect*is permissive (always OK) and enforcement lives solely intransferred, exactly as CMTAT's RuleEngine does — provingpostRun/transferredis an authoritative enforcer, not just a state-updater.
- Deploy the extractor and set it on the PolicyEngine:
const extractor = await ethers.deployContract('ERC20TransferFromExtractor');
const transferSelector = cmtat.interface.getFunction('transfer(address,uint256)').selector;
const transferFromSelector = cmtat.interface.getFunction(
'transferFrom(address,address,uint256)',
).selector;
await policyEngine.setExtractor(transferSelector, await extractor.getAddress());
await policyEngine.setExtractor(transferFromSelector, await extractor.getAddress());- Deploy rule contracts and the policy:
const maxAmountRule = await ethers.deployContract('MaxAmountRule', [1000n]);
const restrictedRule = await ethers.deployContract('RestrictedAddressRule', [[]]);
const configParams = abiCoder.encode(
['address[]'],
[[await maxAmountRule.getAddress(), await restrictedRule.getAddress()]],
);
const policy = await upgrades.deployProxy(
await ethers.getContractFactory('TransferValidationPolicy'),
[policyEngineAddress, adminAddress, configParams],
{
initializer: 'initialize',
unsafeAllow: ['constructor', 'missing-initializer', 'missing-initializer-call'],
},
);- Register the policy for transfer selectors with parameter names:
const PARAM_SPENDER = keccak256(toUtf8Bytes('spender'));
const PARAM_FROM = keccak256(toUtf8Bytes('from'));
const PARAM_TO = keccak256(toUtf8Bytes('to'));
const PARAM_AMOUNT = keccak256(toUtf8Bytes('amount'));
await policyEngine.addPolicy(cmtatAddress, transferSelector, policyAddress, [
PARAM_SPENDER,
PARAM_FROM,
PARAM_TO,
PARAM_AMOUNT,
]);
await policyEngine.addPolicy(cmtatAddress, transferFromSelector, policyAddress, [
PARAM_SPENDER,
PARAM_FROM,
PARAM_TO,
PARAM_AMOUNT,
]);- Rules can be updated at any time by the policy owner:
await policy.setRules([newRuleAddress1, newRuleAddress2]);Implement the IRule interface to create custom transfer restriction logic:
contract MyCustomRule is IRule {
function detectTransferRestriction(
address from,
address to,
uint256 amount
) public view override returns (uint8) {
// Return 0 for allowed, non-zero for rejected
}
function detectTransferRestrictionFrom(
address spender,
address from,
address to,
uint256 amount
) public view override returns (uint8) {
// Validate spender + transfer params
}
function messageForTransferRestriction(
uint8 code
) external pure override returns (string memory) {
// Return human-readable rejection reason
}
// ... canTransfer(), canReturnTransferRestrictionCode()
}You do not have to write rules from scratch: the ready-made rules from CMTA's Rules library (allowlist/whitelist, blacklist, sanctions, conditional transfer, etc.) implement the same IRule interface and can be reused with this integration in two ways:
- Through
TransferValidationPolicy— pass the deployed CMTA rule addresses in the policy's rule array (atinitializeor viasetRules). The policy runs eachIRuleand rejects on any non-zero restriction code, so no rule code changes are needed. This is the recommended path and is what the mock rules demonstrate. - By extending
RuleEnginewith theIPolicyinterface — wrap CMTA's RuleEngine (which already aggregates and orchestrates a set ofIRulecontracts) in a Chainlink ACEPolicyso the whole RuleEngine becomes a single ACE policy. Use this when you want to keep the RuleEngine's rule-management and orchestration logic rather than re-listing individual rules onTransferValidationPolicy.
Both approaches let an issuer reuse the audited CMTA rule set while still gating transfers through the Chainlink ACE PolicyEngine.
This integration includes ERC-165 interface discovery for both the protected token side and policy side:
- Protected-token interface support:
PolicyProtectedBaseUpgradeableexposesIPolicyProtectedviasupportsInterface, and the Standard/Lite token bases propagate that support through their ownsupportsInterfaceoverrides. - Policy interface support:
TransferValidationPolicyextends Chainlink ACEPolicy, andPolicyexposesIPolicyvia ERC-165. - Rule interface support in mocks: the included
TransferRuleMocksexposeIRuleviasupportsInterfacefor compatibility testing.
This allows integrators and tooling to programmatically verify interface compatibility before wiring policies, engines, and rule contracts together.
Both variants advertise the ERC-7943 (uRWA) fungible interface id 0x3edbb4c4 via supportsInterface, and implement its check/enforcement surface:
forcedTransfer,setFrozenTokens,getFrozenTokens— enforcement (from CMTAT'sERC20EnforcementModule).canTransfer(from,to,amount),canSend(account),canReceive(account)— non-reverting view checks.canTransfercombines the unfrozen-balance check,canSend/canReceive, and the PolicyEngine's permissioned rules (queried via the read-onlycheck, mapping a revert tofalse).
Notes:
- Lite uses CMTAT's account freeze, so
canSend/canReceivereturnfalsefor a frozen account. - Standard has no on-chain account allowlist/freeze on the token (send/receive eligibility is decided per transfer by the PolicyEngine inside
canTransfer), socanSend/canReceivereport no token-level account restriction. The authoritative gate iscanTransfer.
Conformance is covered by test/custom/erc7943Compliance.test.js.
- CMTAT v3.3.0-rc1
- Chainlink ACE
1.1.1 - OpenZeppelin Contracts
5.6.1 - OpenZeppelin Contracts Upgradeable
5.6.1
git submodule update --init --recursiveYou can use any package manager either npm, yarn or pnpm. For example you can type:
bun installTo compile
bunx hardhat compileTo run tests:
bunx hardhat testLint JavaScript files (tests, scripts, config):
bun run lintAuto-fix fixable issues:
bun run lint:fixCheck formatting for JS, JSON, Markdown, and Solidity:
bun run format:checkAuto-format all files:
bun run formatSolidity formatting uses prettier-plugin-solidity and is scoped to contracts/**/*.sol only (submodules and dependencies are excluded).
📖 Read the Deployment Guide first. It explains how the scripts work, the risks (selector-coverage completeness,
defaultPolicyAllow, atomic proxy init, extractor/parameter matching, Standard vs Lite responsibilities, roles), and a checklist for using these scripts — or writing your own — without bricking the token or leaving a privileged selector ungated. Always finish with the preflight check.
Individual deployment scripts are available for each contract variant:
| Script | Description |
|---|---|
scripts/lite/deploy-lite-standalone.js |
Lite standalone (no proxy) |
scripts/lite/deploy-lite-upgradeable.js |
Lite transparent proxy |
scripts/lite/deploy-lite-uups.js |
Lite UUPS proxy |
scripts/standard/deploy-standard-standalone.js |
Standard standalone (no proxy) |
scripts/standard/deploy-standard-upgradeable.js |
Standard transparent proxy |
scripts/standard/deploy-standard-uups.js |
Standard UUPS proxy |
Run any script with:
bunx hardhat run scripts/lite/deploy-lite-standalone.jsscripts/demo.js provides a complete end-to-end deployment of the Standard variant with the full Chainlink ACE policy stack. It deploys and wires together all contracts in the correct order:
- PolicyEngine (proxy) — central policy orchestrator with
defaultAllow = true - ComplianceTokenCMTATStandalone — the token contract, attached to the PolicyEngine
- PausePolicy (proxy) — added to all state-changing selectors (mint, burn, transfer, enforcement, admin)
- RoleBasedAccessControlPolicy (proxy) — added to admin selectors with role-to-selector mappings
- MockV3Aggregator — mock Chainlink reserve price feed (Hardhat network only)
- SecureMintPolicy (proxy) — added to
mint(), enforces reserve-backed minting via price feed - MintBurnExtractor — set for
mint()selector, extractsaccountandamountparameters - ERC20TransferExtractor — set for
transfer()selector - ERC20TransferFromExtractor — set for
transferFrom()selector - MaxAmountRule + RestrictedAddressRule — mock IRule contracts for transfer validation
- TransferValidationPolicy (proxy) — added to
transfer()andtransferFrom()with both rules
Documents are managed in-contract via setDocument() (DocumentERC1643Module, DOCUMENT_ROLE); there is no external document or snapshot engine.
The script also configures RBAC operation allowances and grants roles (MINTER_ROLE, BURNER_ROLE, BURNER_FROM_ROLE, ENFORCER_ROLE, ERC20ENFORCER_ROLE, DOCUMENT_ROLE) to the admin account.
Policy execution order per function:
mint()→ PausePolicy → RBAC → SecureMintPolicytransfer()/transferFrom()→ PausePolicy → TransferValidationPolicy- All other state-changing functions → PausePolicy → RBAC
Run the demo on a local Hardhat network:
bunx hardhat run scripts/demo.jsscripts/preflight.js verifies that a deployed token will not have its state-changing operations bricked by the PolicyEngine configuration, and prints per-selector policy coverage.
Important: This integration is designed for
defaultAllow = true. The bundled policies (PausePolicy,RoleBasedAccessControlPolicy,TransferValidationPolicy) returnContinue, neverAllowed, so the engine always falls through to the default. WithdefaultAllow = false, every policy-routed operation (mint/burn/transfer/…) reverts (even selectors that have policies attached), and the token is effectively frozen. The token must also be attached to the engine. The preflight reconstructs the effectivedefaultAllow(global + per-target) and attachment state from on-chain events and exits non-zero if the token would be bricked, so it can gate a deployment pipeline.
POLICY_ENGINE=0x... TOKEN=0x... \
[TOKEN_CONTRACT=ComplianceTokenCMTATLiteStandalone] \
bunx hardhat run scripts/preflight.js --network <network>TOKEN_CONTRACT defaults to ComplianceTokenCMTATStandalone; set it to the Lite artifact when checking a Lite deployment. The invariant tests in test/deployment/preflightPolicyCoverage.test.js assert the preflight verdict matches real on-chain behavior.
This section summarizes the static-analysis reports available in this repository.
⚠️ This project has NOT been formally audited by a security company. Use at your own risk. The reports below are automated static-analysis (Slither, Aderyn) and AI-generated reviews only — they are not a substitute for a professional manual security audit. Do not deploy to production with real value without an independent, professional audit.
🔒 See
doc/audits/AUDIT_OVERVIEW.mdfor the security overview: the latest Slither and Aderyn results (v0.3.0, mocks included), the AI/internal audit findings that were fixed, and the per-tool dispositions. Latest static analysis: 0 High to fix — every tool finding is a false positive, an intentional design choice, environment/mock noise, or cosmetic.
Here is the list of report performed with Slither
slither . --checklist > doc/audits/tools/v0.2.0/slither-report.mdbun run slither generates timestamped reports in the reports/ directory:
- JSON —
reports/slither-report-<timestamp>.json - Markdown —
reports/slither-report-<timestamp>.md
The direct slither ... --checklist command above writes a checklist-style report to doc/audits/tools/v0.2.0/slither-report.md.
| Version | Report | Assessment |
|---|---|---|
| v0.3.0 | slither-report.md | slither-report-feedback.md |
| v0.2.0 | slither-report.md | slither-report-feedback.md |
| v0.1.0 | slither-report.md | slither-report-feedback.md |
Latest (v0.3.0, mocks included): 0 High · 11 Medium · 10 Low · 21 Informational — nothing to fix. The per-finding breakdown and dispositions are in doc/audits/AUDIT_OVERVIEW.md and the feedback file.
Here is the list of report performed with Aderyn
# v0.3.0 — mocks INCLUDED (no -x mocks)
aderyn --output doc/audits/tools/v0.3.0/aderyn/aderyn-report.md
# earlier runs excluded mocks:
aderyn -x mocks --output doc/audits/tools/aderyn-report.md| Version | Report | Assessment |
|---|---|---|
| v0.3.0 | aderyn-report.md | aderyn-report-feedback.md |
| current | aderyn-report.md | aderyn-report-feedback.md |
Latest (v0.3.0, mocks included): 2 High · 11 Low — nothing to fix (the Highs are the accepted-context / false-positive items). The per-finding breakdown and dispositions are in doc/audits/AUDIT_OVERVIEW.md and the feedback file.
⚠️ This report was generated entirely by AI and has not been manually reviewed by Nethermind's security team. It does not constitute a security audit; findings may contain errors or omissions and must be independently verified.
AI-generated review of the v0.2.0 codebase (doc/audits/tools/v0.2.0/nethermind-audit-agent/). Developer triage: audit_agent_report-feedback.md. Outcome: 6 fixed in code, 2 accepted as documented design, 1 informational, 1 false positive — all addressed in the v0.3.0 release.
| ID | Finding | Tool sev | Our verdict | Status |
|---|---|---|---|---|
| NM-1 | Uninitialized proxy hijack via public initialize() |
High | Informational (not exploitable as deployed) | No code change (docs) |
| NM-2 | TransferValidationPolicy never calls stateful IRule.transferred() |
High | Accepted (High) | Fixed |
| NM-3 | Unmapped inherited privileged selectors bypass policy auth (Standard) | Medium | Accepted as design (High; Lite already safe) | No code change; deployment remediated |
| NM-4 | MintBurnExtractor lacks burn(address,uint256) |
Low | Accepted (Medium) | Fixed |
| NM-5 | detectTransferRestriction* reverts when frozen > balance |
Low | Accepted | Fixed |
| NM-6 | Context cleared mid-batch breaks batch ops | Low | Accepted | Fixed |
| NM-7 | burnAndMint bypasses screening in Lite |
Low | Accepted (Medium) | Fixed |
| NM-8 | Standard canSend/canReceive always true |
Info | Fixed (account-level signal added) | Fixed |
| NM-9 | initialize() accepts zero PolicyEngine (Standard) |
Info | False positive | Rejected |
| NM-10 | Lite flows only screened if exact selectors wired | Info | Accepted by design | No code change; deployment + preflight |
Writes coverage files to doc/coverage using solidity-coverage hardhat plugin with config at .solcover.js
bunx hardhat coverage
npx hardhat coverageThis project now documents the policy-protected function selectors explicitly. The list below reflects the selectors wired in deployment/test flows (scripts/demo.js, test/deploymentUtils.js).
| Function signature | Selector |
|---|---|
transfer(address,uint256) |
0xa9059cbb |
transferFrom(address,address,uint256) |
0x23b872dd |
| Function signature | Selector |
|---|---|
mint(address,uint256) |
0x40c10f19 |
burn(address,uint256) |
0x9dc29fac |
burn(uint256) |
0x42966c68 |
burnFrom(address,uint256) |
0x79cc6790 |
forcedTransfer(address,address,uint256) |
0x9fc1d0e7 |
freezePartialTokens(address,uint256) |
0x125c4a33 |
unfreezePartialTokens(address,uint256) |
0x1fe56f7d |
setName(string) |
0xc47f0027 |
setSymbol(string) |
0xb84c8246 |
setTokenId(string) |
0xdcfd616f |
setDocument(bytes32,string,bytes32) |
0x010648ca |
setCCIPAdmin(address) |
0xa8fa343c |
crosschainMint(address,uint256) |
0x18bf5077 |
crosschainBurn(address,uint256) |
0x2b8c49e3 |
Note: exact policy chains per selector (PausePolicy, RBAC, TransferValidationPolicy, etc.) can vary by deployment configuration.
In the Standard variant, the token delegates all authorization to the ACE PolicyEngine: every privileged operation is gated by runPolicy, which evaluates the policies wired for the function's selector (msg.sig). There is intentionally no on-token role check — the engine is the single, authoritative gate. (The Lite variant is different: it keeps CMTAT's native onlyRole(...) access control and uses the engine only for transfer validation, so its authorization does not depend on per-selector wiring.)
A direct consequence for the Standard variant is that its safety is a property of the deployment configuration, not of the contract alone. Two settings interact:
- Selector coverage. Authorization only applies to selectors that have a policy wired. The same privileged logic is reachable through several entrypoints with different selectors — overloads (
mint(address,uint256)vsmint(address,uint256,bytes)), batch variants (batchMint,batchBurn), and multiplexers such asburnAndMint(...)(whose innerburn/mintrun under theburnAndMintselector). Every such privileged selector must be wired, not only the canonical ones. defaultPolicyAllow— the engine's behavior for a selector that has no policy wired:defaultPolicyAllow = true(allow-by-default): an unwired selector is allowed. This is convenient, but it means any privileged selector that was not wired (e.g. an overlooked overload orburnAndMint) is callable without authorization. Under this setting you must wire a policy for every privileged selector.defaultPolicyAllow = false(fail-closed): an unwired selector reverts. Forgetting to wire a selector becomes a safe failure (DoS) instead of an open door. Note the trade-off: the policies shipped here (PausePolicy,RoleBasedAccessControlPolicy,TransferValidationPolicy) returnContinue, neverAllowed— so under fail-closed you must also attach a terminal allow policy (or a policy that returnsAllowed) on each selector you intend to permit, otherwise even correctly-wired operations revert.
Recommendation. For the Standard variant, choose one of:
- Wire an access-control policy for every privileged selector — derived from the full token ABI, including the overloads/batch/multiplexer selectors above — and keep
defaultPolicyAllow = true; or - Deploy with
defaultPolicyAllow = false(fail-closed) plus an explicit terminal allow policy on each permitted selector.
Use the policy preflight check before going live to confirm coverage, and treat any unwired privileged selector as a deployment blocker.
Warning: This FAQ is best-effort guidance for this repository integration. It may be incomplete and is not a substitute for official ACE documentation, legal advice, or a professional security review.
ACE moves compliance checks into separate policy contracts. This lets you update compliance rules without redeploying the token.
Yes.
- Keep CMTAT roles where possible as a second safety layer, so a policy misconfiguration alone is less likely to enable sensitive actions.
- Treat ACE policy configuration as high-privilege admin control: changing policies, ordering, extractors, or
defaultAllowcan effectively allow or block critical token operations.
Use lite if you mainly need policy checks on transfers. Use standard if you also want policy checks on admin and lifecycle actions.
Use a highly trusted governance setup, such as a multisig, DAO, or timelock. Whoever controls PolicyEngine settings effectively controls token compliance behavior.
For token issuers, a common baseline is:
- Pause policy.
- Role-based access policy.
- Transfer restriction policy (for example KYC/sanctions/rule checks).
- A clearly defined default result (
defaultAllow=trueordefaultAllow=false).
Choose based on your operating model:
defaultAllow=true: allow by default, and block only when a policy rejects.defaultAllow=false: reject by default, and allow only when policies explicitly allow.
In ACE, true is the usual default behavior; confirm and document your choice before launch.
Start with restrictive checks, then business-limit checks, and place permissive/bypass behavior only where intentionally needed. A policy that returns Allow stops evaluation of later policies.
Policies may read the wrong values or fail unexpectedly. Treat extractor and parameter mapping as security-critical configuration, and test them like contract code.
Yes. transfer and transferFrom use different selectors, so configure and test both paths separately. Include spender-specific checks for transferFrom.
Use one of the two ACE patterns:
- Preferred for custom functions: pass
contextdirectly withrunPolicyWithContext(context). - For fixed interfaces (like ERC-20 functions): call
setContext(...)and consume it in the same atomic transaction.
Do not leave context pending across transactions.
Use a staged process:
- Propose the change and simulate it in staging.
- Review policy order, extractor mapping, and default outcome.
- Execute through timelock/multisig.
- Monitor events and transfer behavior after deployment.
Monitor:
- Policy add/remove actions.
- Extractor and mapping changes.
defaultAllowchanges (this flips the fallback behavior when all policies returnContinue:true= allow,false= reject).- Policy execution failures.
- Sudden increases in rejected or bypassed actions.
Maintain an audit-ready change log with policy versions, activation times, approval records, and test evidence for each policy update.
- Wrong policy order (accidental early bypass).
- Missing extractor for a protected selector.
- Incorrect parameter names or mapping.
- No tests for revert/context behavior.
- Weak governance around PolicyEngine admin changes.
- Role/admin key setup completed.
- Policy chain and order reviewed.
- Extractor and parameter mapping tested for each selector.
- Default outcome verified for each contract.
- Pause and incident runbook tested.
- Upgrade and rollback plan approved.
Use an incident runbook with clear authority to pause sensitive actions, revert bad policy settings, communicate with counterparties, and re-enable flows in controlled phases.
Yes. Run compliance regression tests for every upgrade, including policy-chain behavior, extractor decoding, and role/authorization invariants.
Publish a short integration guide that includes:
- Which functions are policy-protected (function names/selectors).
- What each policy does in normal operation.
- Common failure cases and the revert reasons integrators may see.
- How admin/policy changes are approved and announced.
- Who to contact for support and incident escalation.
This repository is licensed under the Mozilla Public License 2.0 (MPL-2.0) — see LICENSE — except for a few files that carry a different per-file SPDX-License-Identifier.
Note — mixed licensing, review before production use. The following files are licensed under BUSL-1.1 (Business Source License 1.1), inherited from the Chainlink ACE code they derive from, rather than MPL-2.0:
contracts/modules/chainlink-ace/custom/MintBurnExtractor.solcontracts/modules/chainlink-ace/custom/ERC20TransferFromExtractor.solcontracts/modules/chainlink-ace/mocks/PolicyProtectedUpgradeableMocks.solThe Chainlink ACE dependency (
@chainlink/ace— thePolicyEngineand the bundled policies) is also BUSL-1.1. BUSL-1.1 is a source-available license, not an OSI open-source license: it can restrict commercial/production use until the licensor's change date and terms. Confirm the BUSL-1.1 grant permits your intended deployment (or relicense/replace those files) before shipping to production. TheSPDX-License-Identifierat the top of each file is the authoritative license for that file.

