For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

Type
Called
The module

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.

Field
Meaning

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 RULE module, from canTransfer, 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).max for no limit, 0 for 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 lowers amountMax to it. It reverts with EmptyValidationRange when that falls below amountMin, and with ZeroValue when the requested minimum is zero and nothing is left.

  • Read-only. The function is view in IModule and the compliance calls it through the interface, so it runs as a STATICCALL. A module that writes there reverts, and the movement with it.

  • Open to any caller. UtilityChecker.getTransferDetails asks each rule directly and reports the answer next to name().

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 or 0, 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 TRACKER and writes in afterTransfer.

  • 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) gives positionOf(identity), pendingInOf(identity) and pendingOutOf(identity). A cap on acquisitions counts positionOf plus pendingInOf of the recipient. A floor on holdings counts positionOf minus pendingOutOf of the sender. allowedAmount sees the state before the movement. A TRACKER is not told when the ledger follows a relinked wallet to its new identity, or when the Compliance Owner calls fixPosition: 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 RULE module 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.

It provides
As coded

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), otherwise ComplianceNotSuitableForBindingToModule.

  • ModuleProxy is an ERC1967Proxy with the constructor (implementation, _data). A module is one implementation and one ModuleProxy whose _data encodes initialize.

The lifecycle, from the module's side

The Compliance Owner calls
What the module sees

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), under onlyComplianceCall, emits MaxBalanceSet(compliance, maxBalance). An unset cap is zero and refuses every acquisition, so the module is bound with addAndSetModule.

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 MaxBalancePerIdentityModule and SpenderWhitelistModule.

  • 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 emits IdentityExclusionSet.

  • 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.

  • allowedAmount and moduleCheckSpender write nothing and read from ctx.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 afterTransfer carries onlyComplianceCall.

  • 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.