> For the complete documentation index, see [llms.txt](https://docs.t-rex.network/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.t-rex.network/build/write-a-compliance-module.md).

# 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](/token-and-register/modular-compliance.md).

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

```solidity
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](/token-and-register/the-compliance-ledger.md).
* **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](/governance-and-infrastructure/namespaced-storage-erc-7201.md). 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](/guides/issuer/11.-add-a-compliance-rule.md) and [12 Remove a compliance rule](/guides/issuer/12.-remove-a-compliance-rule.md).

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

```solidity
function allowedAmount(TransferContext calldata ctx) external view override returns (uint256) {
    if (ctx.toIdentity == address(0) || ctx.fromIdentity == ctx.toIdentity) return type(uint256).max;
    IComplianceLedger ledger = IComplianceLedger(ctx.compliance);
    uint256 held = ledger.positionOf(ctx.toIdentity) + ledger.pendingInOf(ctx.toIdentity);
    uint256 cap = _getMaxBalanceStorage().maxBalance[ctx.compliance][getNonce(ctx.compliance)];
    return held >= cap ? 0 : cap - held;
}
```

* **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`.

```solidity
contract ExclusionListModule is AbstractModuleUpgradeable, AccessManagedOwnableUpgradeable {

    /// @custom:storage-location erc7201:example.storage.ExclusionListModule
    struct ExclusionListStorage {
        mapping(address compliance => mapping(uint256 nonce => mapping(address identity => bool))) excluded;
    }

    event IdentityExclusionSet(address indexed compliance, address indexed identity, bool excluded);

    // Pragma and imports as in MaxBalancePerIdentityModule.sol, without IComplianceLedger.
    // As in MaxBalancePerIdentityModule: the storage slot constant and _getExclusionListStorage(),
    // constructor, initialize, moduleTypes, isPlugAndPlay, canComplianceBind, name,
    // supportsInterface and _authorizeUpgrade.

    function setExcluded(address _identity, bool _excluded) external onlyComplianceCall {
        require(_identity != address(0), ErrorsLib.ZeroAddress());
        _getExclusionListStorage().excluded[msg.sender][getNonce(msg.sender)][_identity] = _excluded;
        emit IdentityExclusionSet(msg.sender, _identity, _excluded);
    }

    function isExcluded(address _compliance, address _identity) public view returns (bool) {
        return _getExclusionListStorage().excluded[_compliance][getNonce(_compliance)][_identity];
    }

    function allowedAmount(TransferContext calldata ctx) external view override returns (uint256) {
        if (isExcluded(ctx.compliance, ctx.toIdentity) || isExcluded(ctx.compliance, ctx.fromIdentity)) return 0;
        return type(uint256).max;
    }

}
```

* **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](/build/quickstart.md) 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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.t-rex.network/build/write-a-compliance-module.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
