Write a compliance module
A compliance module is a contract that implements one rule of an asset for ModularCompliance. This page is for the Solidity developer who writes one: the interface, what the compliance passes in and expects back, the rules to respect, the base contract, and a worked example. How an asset composes its modules is in Modular compliance.
What a module is
A module implements IModule (contracts/compliance/modular/modules/IModule.sol). It declares its types in moduleTypes(), and the compliance calls it only for those.
RULE
allowedAmount(ctx)
Returns the largest amount it allows to move.
SPENDER
moduleCheckSpender(ctx)
Returns whether ctx.spender may execute the movement for the sender.
TRACKER
afterTransfer(ctx)
Records what it needs, after the ledger moved.
function allowedAmount(TransferContext calldata ctx) external view returns (uint256);
function moduleCheckSpender(TransferContext calldata ctx) external view returns (bool);
function afterTransfer(TransferContext calldata ctx) external;
function moduleTypes() external pure returns (ModuleType[] memory);One deployment serves every compliance bound to it, with separate settings for each. A module limits movements between holders that are already eligible. Who may hold is the other gate, decided in the TREXRegistry.
The context
Every call carries a TransferContext, built once by the compliance with TransferContextLib.
compliance
The compliance asking. Read the ledger and the settings from it, never from msg.sender.
fromIdentity, toIdentity
The ONCHAINID of each side, already resolved. fromIdentity is zero on a mint, toIdentity on a burn. While a rule is bound, the compliance refuses a wallet that resolves to no identity before any rule is asked. In afterTransfer such a wallet arrives with a zero identity and a wallet key that is not zero, so a TRACKER recognizes a mint or a burn by the zero wallet key.
fromWallet, toWallet
The key of each wallet, a bytes32: an address of the reference chain padded on the left, or keccak256 of the interoperable address of a satellite wallet. Zero on the absent side of a mint or a burn.
amountMin, amountMax
Inclusive bounds. Equal, the exact amount, on a mint, a burn, a transfer on the reference chain and a settled validation. On an issuance, the requested range, with amountMax already capped at what the sending wallet has available.
isIssuance
True while a cross-chain validation is being issued. The sender is then a satellite wallet.
spender
Who executes for the sender, as an ERC-7930 interoperable address: the caller of transferFrom, or the spender named on a validation. Filled for moduleCheckSpender only: in allowedAmount and afterTransfer it is empty, even for a transferFrom.
How the compliance calls a module
allowedAmount
When. On every
RULEmodule, fromcanTransfer, which the token runs before a transfer and a mint, and at the issuance of a validation. Also when a settlement arrives after its validation was discarded, to find whether the executed amount breaches a rule. That answer is a finding, not a permission: the settlement is applied either way, and a breach pauses the issuance of validations for the chain it came from.When not. On a burn, a forced transfer or a recovery. At the issuance of a validation between two wallets of one identity.
Answer. The largest amount allowed:
type(uint256).maxfor no limit,0for a refusal. The compliance keeps the minimum over the rules. A movement on the reference chain passes when its amount is at most that minimum. An issuance lowersamountMaxto it. It reverts withEmptyValidationRangewhen that falls belowamountMin, and withZeroValuewhen the requested minimum is zero and nothing is left.Read-only. The function is
viewinIModuleand the compliance calls it through the interface, so it runs as aSTATICCALL. A module that writes there reverts, and the movement with it.Open to any caller.
UtilityChecker.getTransferDetailsasks each rule directly and reports the answer next toname().
moduleCheckSpender
Called on every SPENDER module from canSpenderCall, which Token.transferFrom runs before the allowance is spent, and at the issuance of a validation that names a spender. All must return true: one false reverts transferFrom with SpenderNotAllowed, or the issuance with ValidationSpenderRefused. It is a STATICCALL too. A direct transfer, a mint and a burn never reach it.
afterTransfer
Called on every TRACKER module after the compliance updated the positions: on a transfer, a mint, a burn, a forced transfer, a recovery and a settled validation. ctx.amountMax is the exact amount that moved. A revert stops a movement on the reference chain: a module that cannot record a movement has to stop it. On a settled validation the satellite has already executed, so a revert only reverts the delivery of the settlement, which stays deliverable. forceRemoveModule is the way out for a module that reverts everywhere.
Rules a module must respect
Be monotonic. A smaller amount is never less acceptable than a larger one. A rule that is not, such as "a multiple of some unit", evaluates
ctx.amountMax, returns the maximum or0, and refuses an issuance whose range is not a point (ctx.amountMin != ctx.amountMax): the satellite may execute any amount inside it.Keep the two questions read-only. A rule that needs to remember something also declares
TRACKERand writes inafterTransfer.Reason on identities, not on addresses. Both arrive resolved. A movement between two wallets of one identity (
ctx.fromIdentity == ctx.toIdentity, not zero) changes no ownership, and a rule about distribution returns the maximum on it.Read positions from the ledger.
IComplianceLedger(ctx.compliance)givespositionOf(identity),pendingInOf(identity)andpendingOutOf(identity). A cap on acquisitions countspositionOfpluspendingInOfof the recipient. A floor on holdings countspositionOfminuspendingOutOfof the sender.allowedAmountsees the state before the movement. ATRACKERis not told when the ledger follows a relinked wallet to its new identity, or when the Compliance Owner callsfixPosition: a module that keeps its own copy of positions drifts. See The compliance ledger.Never call another module. A module talks to the compliance and to the token's identity registry. A fact nobody publishes is kept in its own storage and updated in
afterTransfer.Record before calling out. A module that keeps a counter writes it before any external call. The token's reentrancy guard does not cover the calls a module makes.
Keep the cost bounded. Up to 25 modules are bound to one compliance, every
RULEmodule runs on every transfer and mint, and the compliance sets no gas limit on a module call.
The base contract
Every shipped module inherits AbstractModuleUpgradeable.
Binding
bindCompliance and unbindCompliance, callable only by the compliance concerned, through the proxy. Not virtual.
Access to settings
The modifier onlyComplianceCall: msg.sender must be a bound compliance, otherwise OnlyBoundComplianceCanCall. A setter carries it and writes under msg.sender. The Compliance Owner reaches it through ModularCompliance.callModuleFunction(callData, module), which requires OWNER.
Neutral defaults
allowedAmount returns the maximum, moduleCheckSpender returns true, afterTransfer does nothing under onlyComplianceCall. A declared type with no override lets everything through. An override of afterTransfer restates the modifier.
Batching
MulticallUpgradeable: several settings in one callModuleFunction, encoded as multicall(bytes[]).
Upgradeability
UUPSUpgradeable. initialize calls __AbstractModule_init(), and _authorizeUpgrade is left to the module.
The module writes the rest: moduleTypes (a pure function), name, isPlugAndPlay, canComplianceBind, initialize, _authorizeUpgrade, its settings, and the function of each declared type.
Settings belong to a binding. They live in a mapping keyed by compliance, then by the bind nonce
getNonce(compliance), in the module's own ERC-7201 namespace. The nonce starts at 0 and each unbind increments it, so the settings of a binding stop being read at once, with no loop.A module can refuse a compliance. Binding requires
isPlugAndPlay() || canComplianceBind(compliance), otherwiseComplianceNotSuitableForBindingToModule.ModuleProxyis anERC1967Proxywith the constructor(implementation, _data). A module is one implementation and oneModuleProxywhose_dataencodesinitialize.
The lifecycle, from the module's side
addModule(module)
isPlugAndPlay(), canComplianceBind if needed, then moduleTypes(), which must name at least one type and each type once. Then bindCompliance(compliance), which emits ComplianceBound.
addAndSetModule(module, interactions)
The same, then up to five setting calls.
callModuleFunction(callData, module)
The call, with the compliance as msg.sender. A revert of the module reverts the transaction.
resyncModuleTypes(module)
moduleTypes() is read again, after an upgrade that changed the types. Settings are untouched.
removeModule(module)
unbindCompliance(compliance): the base marks the compliance unbound, increments the nonce and emits ComplianceUnbound. The settings of that binding are no longer read.
forceRemoveModule(module)
Nothing. The module still records the binding, so this address cannot be bound to this compliance again.
The Issuer's side is in 11 Add a compliance rule and 12 Remove a compliance rule.
A shipped rule: MaxBalancePerIdentityModule
It caps what one identity may own, over every wallet and every chain. It declares RULE and stores nothing but its cap.
Exempt: a burn, and a movement between two wallets of one identity. The sender is never limited.
Counted: the recipient's position plus what open validations already promise to it. The answer is the room left under the cap.
Setting:
setMaxBalance(uint256 _max), underonlyComplianceCall, emitsMaxBalanceSet(compliance, maxBalance). An unset cap is zero and refuses every acquisition, so the module is bound withaddAndSetModule.
A shipped SPENDER module is shorter. SpenderWhitelistModule keeps a list of named operators per compliance and nonce, set with allowSpender(bytes) and disallowSpender(bytes), and its moduleCheckSpender returns isSpenderAllowed(ctx.compliance, ctx.spender).
Example: an exclusion list
A list of excluded identities is not among the shipped modules. It fits the interface as a RULE module.
Example only. This sketch is not part of the protocol. It has not been compiled or audited. It follows the structure of
MaxBalancePerIdentityModuleandSpenderWhitelistModule.
Recipient listed:
0. No mint, no transfer and no validation toward that identity, on any of its wallets. The answer does not depend on the amount, so the rule is monotonic.Sender listed:
0. The identity cannot transfer, and no validation is issued out of it. A burn, a forced transfer and a recovery ask no rule, so a Token Agent can still act on the position. Drop the sender test for a list that only stops acquisitions.Zero refused in the setter. Zero is the absent side of a mint or a burn: listing it would refuse every mint.
Storage: per compliance and per bind nonce. Removing the module discards the list. An empty list refuses nothing.
Setter: the Compliance Owner sends
callModuleFunction(abi.encodeCall(ExclusionListModule.setExcluded, (identity, true)), module). The module emitsIdentityExclusionSet.Limits: the issuance of a validation between two wallets of one identity asks no rule. A validation issued before a listing keeps its range. If it settles late, after it was discarded, the rule answers
0: the settlement is still applied, and issuance is paused for the origin chain.
Deploy and govern
scripts/DeployComplianceModules.s.sol deploys SpenderVerificationModule and SpenderWhitelistModule, each behind one ModuleProxy initialized with ACCESS_MANAGER. Then, for every token in TOKENS_ADDRESSES, it reads compliance() and calls addModule for both, signing with DEPLOYER_PRIVATE_KEY, which must hold OWNER there. The list is bound empty, so every transferFrom reverts until allowSpender is called.
A module has its own authority, separate from the suites that bind it: the AccessManager passed to initialize. In the shipped modules _authorizeUpgrade is restricted, so upgradeToAndCall answers to that manager. The repository maps no role to that function, so it falls to the manager's ADMIN_ROLE until one is mapped.
Trust statement. Whoever can upgrade a module can change the rule for every compliance bound to it. The shared modules are deployed and governed by Network governance. An Issuer that wants sole control over a rule deploys its own instance, under its own AccessManager.
Test
The helpers of the Quickstart give a complete suite: TREXSuiteTest, where deployer holds OWNER, and InteropSuiteTest, which adds boundCompliance and the satellite helpers _openEvmChain, _fundSatelliteWallet and _requestValidation.
Under test/integration/
Use it for
modules/MaxBalancePerIdentityModule.t.sol
The reference: deployment behind a ModuleProxy, addAndSetModule, a transfer, a mint, a burn, an issuance, two competing validations, a late settlement.
modules/SpenderWhitelistModule.t.sol, modules/SpenderPolicyCrossChain.t.sol
A spender module, on transferFrom and at issuance.
compliance/ComplianceDispatch.t.sol, compliance/ComplianceModuleTypes.t.sol
Which module is called when, binding and resync.
mocks/CapabilityModules.sol
Fixtures: one module per type, a rule on the sender (LockedSenderModule), a rule that writes.
Checklist
Every type named in
moduleTypes()has its override.allowedAmountandmoduleCheckSpenderwrite nothing and read fromctx.compliance.The rule is monotonic, or refuses a range that is not a point.
A mint, a burn and a movement between two wallets of one identity are each handled on purpose.
Every setter and every override of
afterTransfercarriesonlyComplianceCall.Settings are keyed by compliance and bind nonce, and each change emits an event.
The Issuers that bind the module know who can upgrade it.