diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 34d13395..eb809f99 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,6 +40,33 @@ jobs:
forge test -vvv
id: test
+ # The vendored ERC-3643 `Token.sol` pins `pragma solidity 0.8.30` exactly, so it cannot share
+ # a compilation unit with our 0.8.36. `test/ERC3643Real/**` is in the default profile's `skip`
+ # list and is built by `[profile.erc3643]` instead, which means the step above does NOT run it.
+ # Both commands are required — see the Toolchain section of AGENTS.md / CLAUDE.md.
+ - name: Run Forge tests (ERC-3643 profile)
+ env:
+ FOUNDRY_PROFILE: erc3643
+ run: |
+ forge test -vvv
+ id: test-erc3643
+
+ # `forge build` compiles the deployment scripts but never executes them, and the unit tests
+ # call deploy() directly rather than run(), so neither exercises the broadcast context. That
+ # gap let three scripts revert on every real deployment while CI stayed green
+ # (CLAUDE_ANALYSIS_SCRIPT.md S-1/S-2). A test cannot cover this: Foundry refuses to combine a prank
+ # with a broadcast, so `forge script` itself is the only faithful harness. No key or RPC is
+ # needed -- without --broadcast this is a local simulation.
+ - name: Run deployment scripts (dry run)
+ run: |
+ for s in script/*.s.sol; do
+ name=$(basename "$s" .s.sol)
+ echo "::group::$name"
+ forge script "$s:$name"
+ echo "::endgroup::"
+ done
+ id: scripts
+
- name: Setup NodeJS 20.5.0
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
with:
diff --git a/.gitignore b/.gitignore
index faaf0d18..4d57f4ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,9 @@ FEEDBACK.md
*.dtmp
+
+# LibreOffice lock files
+.~lock.*#
+
+# Artifacts from the erc3643 Foundry profile
+out-erc3643/
diff --git a/.gitmodules b/.gitmodules
index 59c1afd8..62b21360 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -13,3 +13,12 @@
[submodule "lib/openzeppelin-contracts-upgradeable"]
path = lib/openzeppelin-contracts-upgradeable
url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable
+[submodule "lib/ERC-3643"]
+ path = lib/ERC-3643
+ url = https://github.com/ERC-3643/ERC-3643/
+[submodule "lib/chainlink-doc"]
+ path = lib/chainlink-doc
+ url = https://github.com/smartcontractkit/documentation.git
+[submodule "lib/chainlink-ace"]
+ path = lib/chainlink-ace
+ url = https://github.com/smartcontractkit/chainlink-ace.git
diff --git a/AGENTS.md b/AGENTS.md
index bec6fee3..eb9d690c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,26 +26,30 @@ Operation rules that treat `msg.sender` or `getTokenBound()` as a *token identit
`CMTAT._mintOverride` calls `_checkTransferred(_msgSender(), address(0), to, value)`, so **on every mint the minter's address arrives at each rule as `spender`** via the 4-arg `transferred` overload. Plain `transfer()` passes `spender == address(0)` and takes the 3-arg path.
- `RuleWhitelist`, `RuleSpenderWhitelist`, `RuleWhitelistWrapper` explicitly exempt mint/burn from the spender check.
-- `RuleIdentityRegistry`, `RuleBlacklist`, `RuleSanctionsList`, `RuleERC2980` do **not** — they screen the minter. For the deny-lists this is intended; for `RuleIdentityRegistry` it means the minter must itself be identity-verified (see `RESULT.md` F-1).
+- `RuleIdentityRegistry`, `RuleBlacklist`, `RuleSanctionsList`, `RuleERC2980` do **not** — they screen the minter. For the deny-lists this is intended; for `RuleIdentityRegistry` it means the minter must itself be identity-verified (see `CLAUDE_AUDIT.md` F-1).
- `RuleMintAllowance` is the only rule that *uses* the mint spender: it debits `mintAllowance[spender]`.
+- `RuleMaxTotalSupply` and `RuleChainlinkPoR` ignore the spender entirely — they cap *supply*, not identities, and act only when `from == address(0)`.
-Full per-rule semantics (who each rule screens, mint/burn handling, unset-oracle behaviour, stateful?, authoritative view) are tabulated in `doc/technical/RULE_SEMANTICS.md` — consult it before assuming any rule behaves like its siblings.
+Full per-rule semantics (who each rule screens, mint/burn handling, unset-oracle behaviour, stateful?, authoritative view) are tabulated in `doc/technical/guides/RULE_SEMANTICS.md` — consult it before assuming any rule behaves like its siblings.
### Standards conformance (non-negotiable)
Rules that implement a standardized interface must match that standard's semantics, not merely its function signatures. Specs are vendored in `doc/ERCSpecification/` — read them before changing a rule's screening logic.
- **`RuleIdentityRegistry` conforms to ERC-3643 (enforced, I-1).** The spec mandates that **only the receiver** be identity-verified: *"The receiver MUST be whitelisted on the Identity Registry and verified"*; `transferFrom` "works the same way"; `mint` and `forcedTransfer` "only require the receiver"; `burn` "bypasses all checks on eligibility". The sender, the spender and the minter are **not** required to be verified — do not re-add those checks as defaults. Screening the sender **traps de-listed holders** (the spec checks only the receiver precisely so a lapsed investor can still exit their position). Stricter screening is available as an explicit opt-in via the `checkSender` / `checkSpender` flags, both defaulting to `false`.
-- **`isVerified(address(0))` must be `false`** — ERC-3643 defines `isVerified` as "is this wallet a valid investor holding the required claims", and `address(0)` is not a wallet. Likewise `RuleERC2980`'s `whitelist(address)` / `frozenlist(address)` are MANDATORY ERC-2980 getters and must not return `true` for `address(0)`. **Enforced (I-12):** mint/burn permission is an explicit `allowMint` / `allowBurn` flag, and the zero address can never enter any list — single adds revert, batch adds skip it. Never re-introduce "whitelist `address(0)` to enable mint/burn".
+- **`isVerified(address(0))` must be `false`** — ERC-3643 defines `isVerified` as "is this wallet a valid investor holding the required claims", and `address(0)` is not a wallet. Likewise `RuleERC2980`'s `whitelist(address)` / `frozenlist(address)` are MANDATORY ERC-2980 getters and must not return `true` for `address(0)`. **Enforced (I-12):** mint/burn permission is an explicit `allowMint` / `allowBurn` flag, and the zero address can never enter any list — **both single and batch adds revert on it**. The batch functions skip *duplicates* but reject `address(0)`, deliberately: silently dropping it would make the emitted `AddAddresses` event name the sentinel as a set member, re-polluting the off-chain view the guard exists to keep clean. Never re-introduce "whitelist `address(0)` to enable mint/burn".
## Key Directories
| Path | Description |
|---|---|
| `src/rules/validation/` | Read-only rules (view functions, no state changes during transfer) |
| `src/rules/operation/` | Read-write rules (modify state on transfer) |
-| `src/rules/validation/abstract/core/` | `RuleTransferValidation` (ERC-1404/3643/7551 views), `RuleNFTAdapter` (ERC-7943 + `ITransferContext` overloads), `RuleWhitelistShared` |
+| `src/rules/validation/abstract/core/` | `RuleTransferValidation` (ERC-1404/3643/7551 views), `RuleNFTAdapter` (ERC-7943 + `ITransferContext` overloads), `RuleWhitelistShared`, `TokenSupplyReader` (revert-free `totalSupply()` read, shared by `RuleMaxTotalSupply` and `RuleChainlinkPoR`), `ChainlinkPoRFeedManager` (PoR feed configuration + the revert-free reserve read; **no constructor, no ERC-1404**, so it is reusable and initializer-agnostic), `TotalSupplyCapManager` and `BalanceCapManager` (the same split for `RuleMaxTotalSupply` and `RuleMaxBalance`: state, setters and the revert-free read, with the constructor and the restriction-code mapping left in the rule) |
| `src/rules/validation/abstract/` | Shared base contracts and invariant storage |
-| `src/rules/interfaces/` | Shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITotalSupply`, `ITransferContext`, `IERC2980`, `IERC7943NonFungibleCompliance`) |
+| `src/rules/interfaces/` | Shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITotalSupply`, `ITransferContext`, `IERC2980`, `IERC7943NonFungibleCompliance`, `AggregatorV3Interface`, `IDecimals`) |
+| `src/registry/` | Contracts filling a token's **identity registry** slot, not its compliance slot (`IdentityRegistryWhitelist`). Not rules: no `IRule`, never added to a RuleEngine |
| `src/modules/` | Reusable modules (`AccessControlModuleStandalone`, `MetaTxModuleStandalone`, `VersionModule`, `Ownable2StepERC165Module`) |
+| `doc/technical/contracts/` | One documentation page per deployable contract |
+| `doc/technical/guides/` | Cross-cutting docs: `RULE_SEMANTICS.md`, `INVARIANT_TESTS.md`, `DEPLOYMENT_SCRIPTS.md` |
| `test/` | Foundry tests, one folder per rule |
| `lib/` | Git submodule dependencies (do not edit) |
@@ -61,17 +65,21 @@ Rules that implement a standardized interface must match that standard's semanti
| Contract | Role |
|---|---|
| `RuleWhitelist` / `RuleWhitelistOwnable2Step` | Allow transfers only between whitelisted addresses |
+| `RuleReceiverWhitelist` / `RuleReceiverWhitelistOwnable2Step` | Screen **only the receiver**, reproducing ERC-3643 eligibility. Sender and spender are never checked — do not add those, it traps de-listed holders (same reasoning as I-1). Burn is exempt (`to == address(0)` can never be listed); mint is screened on the receiver with no `allowMint` flag. Code 81 |
| `RuleWhitelistWrapper` / `Ownable2Step` | Aggregate multiple whitelist rules (OR logic) |
| `RuleBlacklist` / `RuleBlacklistOwnable2Step` | Block transfers involving blacklisted addresses |
| `RuleSanctionsList` | Block sanctioned addresses via Chainalysis oracle |
+| `RuleMaxBalance` / `RuleMaxBalanceOwnable2Step` | Cap how many tokens **one address** may hold, with an operator-managed exemption list. Screens the receiver only; burns exempt; mints capped. Codes 82, 83. **Bypassable by splitting across wallets** — pair with a rule admitting one address per investor (`RuleWhitelist` / `RuleReceiverWhitelist` / `RuleIdentityRegistry`) and an onboarding policy of one address per entity. `maxBalance = 0` forbids holding, it does NOT disable the rule |
| `RuleMaxTotalSupply` | Cap minting so total supply never exceeds a maximum |
+| `RuleChainlinkPoR` / `RuleChainlinkPoROwnable2Step` | Cap minting at the reserves reported by a Chainlink Proof of Reserve feed (`AggregatorV3Interface`). The limit equals the reported reserves exactly — no margin parameter (deliberately dropped from Chainlink's `SecureMintPolicy`); compose with `RuleMaxTotalSupply` for a static cap. Mints only; transfers and burns always pass, so a stale or broken feed never traps holders. The read path is guarded (`code.length` check + `try/catch` + saturating arithmetic) so the ERC-1404 views never revert |
| `RuleIdentityRegistry` | Check ERC-3643 identity registry for participant verification |
+| `IdentityRegistryWhitelist` | The mirror image of `RuleIdentityRegistry`: **is** an ERC-3643 identity registry, backed by a whitelist, so no ONCHAINID is needed. Keeps **no identity state** — `_identity` and `_country` are accepted for signature compatibility then discarded, and `investorCountry` is a constant 0; do not add identity storage back. Inherits `RuleAddressSetInternal` (the same set machinery as `RuleWhitelist`) rather than deploying or re-implementing a whitelist — **only the internal layer**, because `RuleAddressSet`'s public `addAddress`/`removeAddress` are not `virtual` and so could not maintain the `keyHasPurpose` reverse index; a second write path would produce verified-but-unrecoverable wallets. Installed with `token.setIdentityRegistry()`. Implements **no** ERC-734 surface: `recoveryAddress` needs a real ONCHAINID as `_investorOnchainID`. A `keyHasPurpose` implementation was tried and removed — the agent chooses which contract that call lands on, so it added no security while forcing a hash-to-wallet reverse index and duplicate-tolerant registration. Do not re-add it. The token itself must hold `IDENTITY_REGISTRAR_ROLE`. See `doc/technical/contracts/IdentityRegistryWhitelist.md` |
| `RuleSpenderWhitelist` / `RuleSpenderWhitelistOwnable2Step` | Allow `transferFrom` only when spender is whitelisted; direct transfers are always allowed |
| `RuleERC2980` | ERC-2980 Swiss Compliant rule: whitelist (recipient-only) + frozenlist (blocks sender, recipient, and spender for `transferFrom`); frozenlist takes priority |
| `RuleERC2980Ownable2Step` | Ownable2Step variant of RuleERC2980 |
| `RuleConditionalTransferLight` | Require operator approval before each transfer; bound to exactly one token at a time (`bindToken` reverts if a token is already bound; use `unbindToken` first to migrate) |
| `RuleConditionalTransferLightOwnable2Step` | Owner-only approval and execution for conditional transfers |
-| `RuleConditionalTransferLightMultiToken` / `…Ownable2Step` | Conditional transfers with approvals keyed `(token, from, to, value)`. **Direct-binding-only (Topology B)** — approvals are *consumed* under `msg.sender`, so this rule must NOT be added to a RuleEngine; behind an engine it either reverts or loses all per-token isolation. See `RESULT.md` F-4 and `doc/technical/RuleConditionalTransferLightMultiToken.md` |
+| `RuleConditionalTransferLightMultiToken` / `…Ownable2Step` | Conditional transfers with approvals keyed `(token, from, to, value)`. **Direct-binding-only (Topology B)** — approvals are *consumed* under `msg.sender`, so this rule must NOT be added to a RuleEngine; behind an engine it either reverts or loses all per-token isolation. See `CLAUDE_AUDIT.md` F-4 and `doc/technical/contracts/RuleConditionalTransferLightMultiToken.md` |
| `RuleMintAllowance` / `RuleMintAllowanceOwnable2Step` | Per-minter mint quota, debited on the 4-arg `transferred(spender, from=0, to, value)` path. Requires CMTAT ≥ v3.3. `canTransfer` is **not** authoritative for this rule — use `canTransferFrom(minter, address(0), to, value)` |
| `AccessControlModuleStandalone` | Base RBAC module; admin implicitly holds all roles |
| `MetaTxModuleStandalone` | ERC-2771 meta-transaction support. Note: the operation rules deliberately do **not** inherit this, so `_msgSender()` used as a binding identity is never forwarder-controlled |
@@ -79,21 +87,44 @@ Rules that implement a standardized interface must match that standard's semanti
| `VersionModule` | Implements `IERC3643Version`; returns the contract version string |
## Dependencies (lib/)
-- `openzeppelin-contracts` v5.6.1 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context`
-- `openzeppelin-contracts-upgradeable` v5.6.1
-- `CMTAT` v3.0.0 — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces
-- `RuleEngine` v3.0.0-rc4 — `IRule`, `RulesManagementModule`
+- `openzeppelin-contracts` v5.7.0 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context`
+- `openzeppelin-contracts-upgradeable` v5.7.0
+- `CMTAT` v3.3.0-rc3 (submodule pin; library supports ≥ v3.0.0) — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces
+- `RuleEngine` v3.0.0-rc5 — `IRule`, `RulesManagementModule`
- `forge-std` — Foundry test utilities
-Remappings are in `remappings.txt`; aliases used in source: `OZ/`, `CMTAT/`, `RuleEngine/`.
+Remappings are in `remappings.txt`; aliases used in source: `@openzeppelin/`, `CMTAT/`, `RuleEngine/`.
## Toolchain
```bash
forge build # compile
forge test # run all tests
forge test -vvv # verbose output
+
+FOUNDRY_PROFILE=erc3643 forge test # the real-ERC-3643-token suite (see below)
```
-Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
+Foundry config: `foundry.toml` (solc 0.8.36, EVM prague, optimizer 200 runs).
+
+**There are two profiles, and `forge test` alone does not run everything.** The vendored ERC-3643
+`Token.sol` pins `pragma solidity 0.8.30` *exactly*, which cannot share a compilation unit with our
+0.8.36. So `test/ERC3643Real/**` is in the default profile's `skip` list and is built by
+`[profile.erc3643]` at solc 0.8.30 instead (our contracts are `^0.8.20`, so they compile there too).
+CI must run **both** commands. Gotchas: profiles inherit unspecified keys from `[profile.default]`,
+so that profile has to clear `skip = []` explicitly; and it writes to `out-erc3643/` to avoid
+clobbering the 0.8.36 artifacts.
+
+ERC-3643 imports `@onchain-id/solidity`, which is an npm package rather than a submodule and so is
+not vendored. `test/utils/onchainid/` holds minimal `IIdentity` / `IClaimIssuer` stubs wired in by a
+**context-scoped** remapping (`lib/ERC-3643/:@onchain-id/solidity/contracts/=test/utils/onchainid/`)
+so they apply to the ERC-3643 build only. Only `keyHasPurpose` is ever called; everywhere else those
+types appear as parameters or event fields, which canonicalise to `address` and affect no selector.
+
+That remapping is declared as `remappings = [...]` inside `[profile.erc3643]` in `foundry.toml`, **not
+in `remappings.txt`** — and it must stay there. `forge remappings` prints `remappings.txt` for every
+profile; `hardhat-foundry` runs exactly that command and rejects any line containing a `:` with
+*"remapping contexts are not allowed"*, which breaks `npx hardhat test` (a CI step). As profile
+config it is applied only when that profile is selected, so the default and `ci` profiles Hardhat
+sees stay context-free. Hardhat compiles only `src/` (Foundry's `src`), so it never needs the stubs.
## Restriction Code Ranges
| Rule | Codes |
@@ -102,11 +133,14 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
| RuleSanctionsList | 30–32 |
| RuleBlacklist | 36–38 |
| RuleConditionalTransferLight / …MultiToken | 46 |
-| RuleMaxTotalSupply | 50 |
+| RuleMaxTotalSupply | 50, 51 (total supply unavailable) |
| RuleIdentityRegistry | 55–57 |
| RuleERC2980 | 60–63, 64 (mint not allowed), 65 (burn not allowed) |
| RuleSpenderWhitelist | 66 |
| RuleMintAllowance | 70 |
+| RuleChainlinkPoR | 75 (reserves exceeded), 76 (feed stale), 77 (answer returned but unusable), 78 (total supply unavailable), 79 (feed unreadable) |
+| RuleReceiverWhitelist | 81 |
+| RuleMaxBalance | 82, 83 (balance unavailable) |
## Conventions
- Each rule has an `InvariantStorage` abstract contract holding its constants, custom errors, and events.
@@ -115,26 +149,39 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
- **All `_authorize*()` / `_only*()` access-control hooks are `internal view virtual`** — both the abstract declaration and every override. An authorization hook checks and reverts; it must never mutate state, and `view` makes that a compiler-enforced invariant rather than a convention. It is free: these are `internal`, so `view` costs no gas and changes no runtime behaviour. Both OZ check functions (`AccessControl._checkRole`, `Ownable._checkOwner`) are `view`, so every hook can be.
- **One documented exception**: `RuleConditionalTransferLightMultiTokenBase._authorizeComplianceBindingChange` cannot be `view`, because it delegates to `_onlyComplianceManager()`, which `lib/RuleEngine` declares non-`view` (Solidity checks mutability against a virtual's *declared* type, not the installed override). If you hit this constraint elsewhere, document why inline — do not silently drop `view` from a hook.
- AccessControl variants treat the default admin as having all roles via `hasRole`, but the admin may not appear in role member enumerations unless explicitly granted.
-- All rules implement `IERC3643Version` via `VersionModule`; the current version string is `"0.4.0"` (asserted by `test/Version.t.sol`).
+- All rules **and `IdentityRegistryWhitelist`** implement `IERC3643Version` via `VersionModule`; the current version string is `"0.5.0"`. `test/Version.t.sol` asserts it for every deployable contract — keep it exhaustive, a half-covered version test reads as authoritative while missing the mirror it exists to catch.
- **ERC-165 interface IDs**: `type(IFoo).interfaceId` only XORs selectors defined directly on `IFoo` and does NOT include selectors from inherited interfaces. Always use the pre-computed library constants instead: `ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID` (from `CMTAT/library/`), `RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID` (from `CMTAT/library/`), and `RuleInterfaceId.IRULE_INTERFACE_ID` (from `RuleEngine/modules/library/`). If no pre-computed constant exists for an interface, define a flat mock interface that redeclares all functions from the full inheritance tree and use `type(IFooFlattened).interfaceId` to compute the correct value (see `lib/RuleEngine/src/mocks/IRuleInterfaceIdHelper.sol` for the established pattern).
-- Batch add/remove operations are non-reverting (skip duplicates); single-item operations revert on invalid input.
+- Batch add/remove operations are non-reverting **for duplicates and missing entries** (those are skipped and counted); single-item operations revert on the same input. The one exception is `address(0)`, which **reverts on every add path, batch included** — see I-12. A batch containing a zero entry therefore fails as a whole rather than being partially applied.
- All `internal` functions should be marked `virtual`.
- Do not create git commits; provide commit messages only when requested.
- Always run full tests (`forge test`) after any code modification, including lint-driven or mechanical refactors, before reporting completion.
- Use `require(condition, CustomError(...))` for custom errors; avoid direct `revert CustomError(...)`.
+- **Keep NatSpec blocks short — 20 lines is the ceiling, and most should be far shorter.** Measured over `src/`, the median block is **4 lines** and the 90th percentile is **8**; anything past 20 is an outlier that has stopped being a comment and become a document. A reader opening a contract wants to know what it does and what will bite them, not read an essay before the first line of code. Long blocks also rot faster: the more claims a comment makes, the more of them silently go stale.
+ - **State the conclusion and the warning; leave the derivation to `doc/technical/`.** This is the same rule as the no-cross-reference convention above, applied from the other end — that one says do not replace the substance with a pointer, this one says do not inflate the substance into a treatise. Both point at the same target: the comment carries what a reader with only the verified source must know, and the doc carries the reasoning.
+ - **What earns its place in a long block**: a safety precondition (`must never revert`, and why that holds), a footgun (`maxBalance = 0` forbids holding, it does not disable the rule), and a non-obvious design constraint. **What does not**: restating what the code says, narrating the refactor that produced the file, or listing benefits.
+- **Never reference a `doc/technical/` page from contract code.** NatSpec and comments in `src/` must not cite `doc/technical/contracts/*.md` or `doc/technical/guides/*.md`, by path or by bare filename. Documentation paths move — the `contracts/` + `guides/` split rewrote four source comments that had been correct the day before — and a stale pointer inside a deployed contract's source cannot be fixed by editing the docs. **Write the substance into the comment instead**: a reader with only the verified source must get the whole warning, not a breadcrumb to a file they may not have. If the explanation is too long for NatSpec, it is a sign the comment should state the conclusion and the doc should carry the derivation, with no cross-reference in the code.
+ - **Exception: `src/mocks/`.** Test doubles are never deployed as production contracts and exist to serve the test suite, so a pointer to the page explaining what they stand in for is useful and carries no cost.
+ - **Audit reports are a separate case and stay allowed**, cited by bare filename (`CLAUDE_AUDIT.md`, `CLAUDE_ANALYSIS.md`, `CLAUDE_ANALYSIS_SCRIPT.md`, `CLAUDE_ANALYSIS_MAXBALANCE.md`). They are immutable historical records of a finding, the bare filename survives the file being moved, and the finding ID is what gives a reviewer the context a comment cannot restate.
- **No emoji in code comments or NatSpec.** Use a plain word marker instead: `WARNING:`, `NOTE:`, `IMPORTANT:`. Emoji render inconsistently across editors, terminals, `forge doc` output and diffs; they are not searchable (`grep WARNING` finds the marker, `grep ⚠️` depends on the shell); and they encode as multi-byte sequences that can be silently mangled by tooling. This applies to `src/`, `test/` and `script/`. Markdown documentation may use emoji freely — the restriction is Solidity comments only.
- `AGENTS.md` and `CLAUDE.md` are identical — always update both together.
-- Always update README.md with the latest change
-- New rule or features implemented: create/update technical documentation in `doc/technical`, update README, create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
+- **Two READMEs.** `README.md` at the root is a short summary (purpose, architecture, rule list, quick start) and is the GitHub front page; `doc/README.md` is the full reference. Update `doc/README.md` with the latest change, and the root `README.md` only when the summary itself becomes wrong (a new rule, a changed code range, a moved document). Links inside `doc/README.md` are relative to `doc/`.
+- New rule or features implemented: create/update technical documentation — a per-contract page in `doc/technical/contracts/`, or `doc/technical/guides/` when the material spans several contracts, update `doc/README.md` (and the root summary if the rule table or code ranges change), create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit.
## Security Findings Reference
-- [`THREAT_MODEL.md`](THREAT_MODEL.md) — trust model, 30 catalogued threats with IDs, data-flow diagrams, 12 invariants.
-- [`RESULT.md`](RESULT.md) — findings (0 High/Medium, 2 Low, 8 Info), invariant and access-control verification, disposition of every threat ID.
-- [`TEST_IMPROVEMENT.md`](TEST_IMPROVEMENT.md) — test-gap analysis and the deferred test backlog.
+- [`doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md`](doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — the v0.4.0 security audit: trust model, catalogued threats and invariants, findings (0 High/Medium, 2 Low, 8 Info), and the disposition of every threat ID. Source comments cite it by bare filename, `CLAUDE_AUDIT.md`.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md) — code-quality review (duplication, missing events, gas, `virtual` convention, behaviour at odds with the library's purpose). 28 findings with the disposition and commit for each, including two whose gas claims were wrong and one whose proposed remedy did not work. Source comments cite it by bare filename, `CLAUDE_ANALYSIS.md`, so the path can move.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md) — deployment-script review (`script/`). 12 findings, all implemented, including three scripts that reverted under `forge script` and a test-methodology gap that hid it. Source comments cite it by bare filename, `CLAUDE_ANALYSIS_SCRIPT.md`, so the path can move.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md) — code-quality review of `RuleMaxBalance` and the `ChainlinkPoRFeedManager` split. 12 findings, 2 implemented, 4 deliberately left, including a measured decision to KEEP the exemption-before-balance check order and the pre-update accounting assumption the cap rests on. Source comments cite it by bare filename, `CLAUDE_ANALYSIS_MAXBALANCE.md`.
- [`test/ThreatModel/ThreatModelTests.t.sol`](test/ThreatModel/ThreatModelTests.t.sol) — 18 PoCs. Tests suffixed `_CurrentBehaviour` assert behaviour the audit considers wrong; **fixing the underlying issue must make them fail**, at which point update the test and the finding together.
Gotchas worth knowing before you change anything:
+- **Deployment scripts must take the deployer as a parameter, never read `address(this)`.** Under `forge script` the broadcaster makes the calls, not the script contract, and Foundry rejects `address(this)` inside a broadcast outright. A script that reads it passes every unit test and reverts on the real deployment path, because the tests call `deploy()` directly and never enter a broadcast context. **No unit test can cover this**: Foundry refuses to combine a prank with a broadcast, so `run()` cannot be faithfully exercised from `forge test`. The guard is the `forge script` dry-run step in CI, which runs every `script/*.s.sol`. Shared token metadata and env configuration live in `script/base/CMTATDeploymentBase.sol`. See `CLAUDE_ANALYSIS_SCRIPT.md`.
- `HelperContract` already inherits `RuleConditionalTransferLightInvariantStorage`; inheriting the multi-token variant alongside it is a compile error (`OPERATOR_ROLE`, `CODE_TRANSFER_REQUEST_NOT_APPROVED` clash).
- `RuleWhitelistWrapperBase._detectTransferRestrictionForTargets` short-circuits once every target address is resolved, so a broken child rule may never be reached for some address pairs.
- `RuleWhitelistWrapper` does not ERC-165-check its child rules (unlike `RuleEngineBase._checkRule`); a non-`IAddressList` child bricks the scan.
+- `RuleChainlinkPoR` reads the feed's `decimals()` **live on every check** and deliberately does NOT cache it. Caching saves ~2,900 gas per mint but lets an aggregator migration that changes decimals mis-scale the reserves by `10 ** delta` with no on-chain signal — in the overstating direction that is unlimited unbacked minting. Both feed calls share the `code.length` guard (Solidity's extcodesize revert on a `try` to a codeless address is uncatchable) and `MAX_FEED_DECIMALS` is re-checked at read time, not just at configuration. Do not "optimise" this back into a cache.
+- `RuleChainlinkPoR` (and `RuleMaxTotalSupply`) protect **one token per instance** with no on-chain guard: they read `totalSupply()` from the configured `tokenContract`, never from the token that triggered the check, and behind a RuleEngine they cannot learn that identity. One instance added to two RuleEngines evaluates both tokens against the first one's supply and feed — silently over-minting or freezing the second. Chainlink's `SecureMintPolicy` blocks this with `onInstall`/`PolicyAlreadyBound`; adding an equivalent here would mean making a stateless validation rule bindable, which is a library-wide decision. Documented, not fixed.
+- `ADDRESS_LIST_ADD_ROLE` / `ADDRESS_LIST_REMOVE_ROLE` live in `RuleAddressSetRolesStorage`, inherited by `RuleAddressSet` (the public layer that enforces them) — **not** by `RuleAddressSetInternal`. Do not move them back into `RuleAddressSetInvariantStorage`: a contract reusing only the internal layer (`IdentityRegistryWhitelist`) would then publish two roles it never checks, and an operator granting one would get no privilege and no signal.
+- `RuleChainlinkPoR` and `RuleMaxTotalSupply` both guard `tokenContract.totalSupply()` with a code-length check plus `try/catch`, returning a restriction code (78 and 51 respectively) rather than reverting, and both validate the token at configuration (non-zero, has code, `totalSupply()` callable). The shared mechanics — the revert-free read and the configuration probe — live in `TokenSupplyReader`; each rule supplies its own token via the `_supplyToken()` hook (so the base holds **no storage** and cannot reorder any rule's slots) and raises its own named errors. Do not move the `require`s into the base: three distinct configuration failures deliberately keep three distinct per-rule errors. The ERC-1404 views MUST NOT revert — never call `totalSupply()` unguarded on a read path. Neither rule re-checks `code.length` at read time: `try/catch` cannot catch a call to a codeless address (the ABI decoder fails in the caller's frame, outside `catch`'s reach -- **not** `EXTCODESIZE`, which solc >= 0.8.10 skips when return data is expected), but the setters require code and EIP-6780 (Cancun) makes that permanent, so the check would be unreachable. This is a **deployment precondition** (Cancun or later, `foundry.toml` targets `prague`), documented in each rule doc rather than enforced at runtime — do not re-add the guard unless targeting a pre-Cancun chain. `decimals()` stays optional on the token; `totalSupply()` is mandatory. The two rules use *different* constant names for the same idea (`CODE_TOTAL_SUPPLY_UNAVAILABLE` vs `CODE_SUPPLY_ORACLE_UNAVAILABLE`) because `HelperContract` inherits both invariant-storage contracts and identical identifiers would clash.
+- `RuleChainlinkPoR` accepts `tokenDecimals == 0`. Chainlink's `SecureMintPolicy` requires 1–18, but CMTAT equity tokens report 0 decimals, so the lower bound was dropped. Do not re-add it.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 932d5f68..d22c057f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,8 +49,267 @@ Custom changelog tag: `Dependencies`, `Documentation`, `Testing`
_Nothing yet._
+## v0.5.0 -
+
+Commit: _see `doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md` for the per-finding commit map._
+
+### Summary
+
+Four new contracts, one behavioural hardening with a migration note, a reviewed and repaired set of
+deployment scripts, the first tests that run against a real ERC-3643 token, and updated dependencies
+(CMTAT `v3.3.0-rc3`, RuleEngine `v3.0.0-rc5`, OpenZeppelin `v5.7.0`, solc `0.8.36`).
+
+Every deployable contract reports `version()` → `"0.5.0"`, asserted exhaustively by `test/Version.t.sol`.
+
+**New**
+- **`RuleChainlinkPoR`** — caps total supply at the reserves reported by a Chainlink Proof of Reserve feed. Restriction codes `75`–`79`.
+- **`RuleReceiverWhitelist`** — screens **only the receiver**, reproducing ERC-3643 eligibility as a CMTAT compliance rule. Restriction code `81`.
+- **`IdentityRegistryWhitelist`** — a whitelist that fills an ERC-3643 token's *identity registry* slot, so a token can enforce investor eligibility with no ONCHAINID deployment. Not a rule: it implements no `IRule` and must never be added to a `RuleEngine`.
+- **`RuleMaxBalance`** — caps how many tokens a **single address** may hold, with an operator-managed exemption list. Restriction codes `82`, `83`. **Bypassable by splitting a position across wallets**, so it must be paired with a rule admitting one address per investor.
+
+**Deployment scripts.** All four scripts in `script/` were reviewed and fixed; see [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md)
+for the twelve findings. Three of them (`DeployCMTATWithBlacklist`, `DeployCMTATWithWhitelist`,
+`DeployCMTATWithBlacklistAndSanctionsList`) reverted under `forge script` because they read
+`address(this)` inside a broadcast, so they could not deploy anything; they now take the deployer as an
+explicit parameter. `DeployCMTATWithWhitelist` also deployed with `allowMintBurn = false`, producing a
+token that could not be issued at all (mint rejected with code `24` even to a whitelisted investor); it
+now allows mint and burn. Shared token metadata moved to the new
+`script/base/CMTATDeploymentBase.sol`, which also adds environment-variable configuration (`CMTAT_NAME`,
+`CMTAT_SYMBOL`, `SANCTIONS_ORACLE`, `CMTAT_MAX_SUPPLY` and others, all with the previous constants as
+defaults) and labelled address logging. The scripts are documented in the new
+`doc/technical/guides/DEPLOYMENT_SCRIPTS.md`. CI now runs every script as a local dry run, which is the only
+faithful harness: Foundry refuses to combine a prank with a broadcast, so no unit test can exercise
+`run()`.
+
+**Breaking behaviour: `RuleMaxTotalSupply`.** The constructor and `setTokenContract` now reject a
+non-contract token and probe that `totalSupply()` is callable, and a token that later reverts yields
+the new restriction code `51` instead of breaking the MUST-NOT-revert views. Deployments that passed
+a placeholder address now fail at construction — see *Changed* for the migration note. This only
+rejects configurations that could never have worked, which is why it is a MINOR rather than MAJOR
+bump pre-1.0.
+
+**ERC-3643 interoperability.** Both integration directions are now covered end to end: a `RuleEngine`
+in the token's *compliance* slot enforcing `RuleWhitelist`, and `IdentityRegistryWhitelist` in the
+*identity* slot. One suite runs against the genuine vendored `Token.sol` rather than a mock, which
+requires a second Foundry profile — **`forge test` alone no longer runs everything**, see *Testing*.
+
+**Code-quality pass.** A full review of `src/` for duplication, missing events, gas on the storage-read and
+loop paths, `virtual` convention drift, and behaviour that is correct but at odds with the library's purpose.
+Of twenty-eight findings, **twenty-four were implemented**, two were deliberately declined with the reasoning
+recorded (`D-3`, `F-7c`), one is left open (`A-3`), and one needed no change (`A-1`). Three carry
+corrections to the review itself: `B-1` and `B-4` overstated their gas saving — `B-1` was wrong for four of its
+six sites — and `F-2`'s proposed remedy did not work and was replaced. Every gas figure quoted below was
+**measured**, not estimated. Findings, dispositions and the commit for each are in
+[`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md`](./doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md).
+No behavioural change reaches a token holder except where explicitly noted (`RuleSanctionsList` mint/burn
+screening, F-1; `transferFrom` delegation, F-2).
+
+### Added
+
+- **`RuleMaxBalance`** — caps how many tokens a **single address** may hold. One cap applies to every holder, with an operator-managed exemption list. Restriction codes `82` (cap exceeded) and `83` (balance unavailable). Available as `RuleMaxBalance` (AccessControl, `MAX_BALANCE_ROLE`) and `RuleMaxBalanceOwnable2Step`.
+ - **Screens the receiver only.** A transfer is rejected when `balanceOf(to) + value > maxBalance`. Mints are covered by the same check, since a mint raises a balance like any transfer. Burns are exempt, and the sender is never screened: sending tokens away can only lower a balance. The spender on `transferFrom` is irrelevant — the cap constrains who ends up holding, not who moved the tokens.
+ - **⚠️ Bypassable in isolation, by design of what a compliance contract can see.** The cap counts tokens per *address*; an investor with two addresses holds twice the cap and no rule objects. It must be paired with a rule that admits one address per investor (`RuleWhitelist`, `RuleReceiverWhitelist` or `RuleIdentityRegistry`) **and** an onboarding policy of one admitted address per legal entity — a whitelist alone does not close it, since the operator can admit both wallets. The exposure is pinned end to end by `testSplitWalletsBypassTheCapEvenWithAWhitelist`, which shows a combined holding of twice the cap with a whitelist active.
+ - **`maxBalance` has no magic value.** `0` forbids holding entirely; it does **not** disable the rule. A sentinel meaning "unlimited" would turn an operator's attempt to freeze holdings into its opposite. To lift the cap use `type(uint256).max` or remove the rule.
+ - **Revert-free read path.** `balanceOf` is wrapped in `try/catch`, and a token that breaks after configuration yields code `83` rather than reverting the MUST-NOT-revert views. The token is validated at configuration (non-zero, has code, `balanceOf` callable). Burns and exempt receivers are decided before any balance is read, so they keep working even while the token is unreadable.
+ - **Exemptions reuse `RuleAddressSetInternal`**, the same `EnumerableSet` machinery as `RuleWhitelist`, so batch semantics match the library: duplicates skipped and counted, `address(0)` rejected on every add path including batches (invariant I-12).
+ - Documented in [`doc/technical/contracts/RuleMaxBalance.md`](./doc/technical/contracts/RuleMaxBalance.md); 55 tests across unit, `Ownable2Step` access control and a CMTAT + RuleEngine end-to-end suite.
+- Code-quality review of the new rule in [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md`](./doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md). Two findings implemented: the exemption writes gained `_addExemptAddress` / `_removeExemptAddress` internals that own the guards and the event, matching the scalar setters; and the rule's dependence on the token notifying compliance **before** it moves value is now documented and pinned by a mutation-verified test (a token notifying afterwards would double-count and silently halve the cap). The check order was measured and deliberately **kept**: reordering saves 2 311 gas per ordinary transfer but costs 9 689 on every transfer to an exempt address, and exempt addresses are the custodial ones that receive most.
+- **`IBalanceOf`** in `src/rules/interfaces/` — a one-function balance query, so the rule depends on exactly the token surface it calls, matching `ITotalSupply`.
+
+
+- **`RuleChainlinkPoR`** — a validation rule that caps total supply at the reserves reported by a [Chainlink Proof of Reserve](https://docs.chain.link/data-feeds/proof-of-reserve) data feed. Before every mint it reads `latestRoundData()` from the configured `AggregatorV3Interface`, scales the answer from the feed's decimals to the token's, and rejects the mint when `totalSupply + value` would exceed it. Modelled on Chainlink's `SecureMintPolicy` from the ACE policy library, minus its configurable reserve margin. Restriction codes `75` (reserves exceeded), `76` (feed stale), `77` (a round was returned but is unusable — negative reserve or incomplete round), `78` (total supply unavailable), `79` (the feed could not be read at all). Available as `RuleChainlinkPoR` (AccessControl) and `RuleChainlinkPoROwnable2Step`.
+ - **Limit = reserves, exactly** — no margin, buffer or headroom parameter. Compose with `RuleMaxTotalSupply` for a static cap, or report conservative reserves upstream for a cushion.
+ - **Staleness threshold** — `maxStalenessSeconds` rejects mints when the feed has not been updated recently; `0` disables the check.
+ - **Mints only** — transfers and burns always pass, including while the feed is stale or unavailable, so a lapsed feed never traps holders.
+ - **Feed decimals read live, never cached** — the extra `STATICCALL` costs ~2,900 gas per mint (+2.6%), which buys immunity to an aggregator migration silently mis-scaling the reserves by `10 ** delta`. In the overstating direction a cached value would authorise unbacked minting with no on-chain signal. Rationale, measurements and residual risk are in the [rule doc](./doc/technical/contracts/RuleChainlinkPoR.md#why-the-decimals-are-read-live-and-what-it-costs).
+ - **Feed failures are reported by kind, not lumped together.** `79` means no usable response could be obtained; `77` means a round came back and its contents are unusable. Both block the mint identically, but the restriction code is the only diagnostic channel a non-reverting view has, so the distinction is worth a code: `79` says check feed liveness, `77` says check that the configured address is really a Proof of Reserve feed.
+ - **Revert-free read path** — one `code.length` check covers both feed calls (Solidity's extcodesize revert on a `try` to a codeless address is uncatchable), `decimals()` and `latestRoundData()` are both wrapped in `try/catch`, `MAX_FEED_DECIMALS` is re-checked at read time so the scaling exponent cannot overflow, decimal scaling saturates rather than overflows, and the supply comparison uses remaining headroom. The ERC-1404 / ERC-3643 views therefore return a code instead of reverting under every feed failure mode.
+ - **Token validated at configuration** — a non-contract address is rejected explicitly (`RuleChainlinkPoR_TokenIsNotAContract`) and `totalSupply()` is probed (`RuleChainlinkPoR_TokenTotalSupplyUnavailable`), so a token that cannot serve the restriction check fails loudly at setup instead of silently bricking the read path. At run time a reverting or codeless token yields code `78` rather than a revert.
+ - `maxBackedSupply()` previews the current limit without simulating a mint.
+ - **ERC-20 only.** Like `RuleMaxTotalSupply`, the rule exposes no ERC-7943 `tokenId` overloads and no `ITransferContext` entrypoints — it inherits `RuleTransferValidation` rather than `RuleNFTAdapter` — and it requires an aggregate `totalSupply()`, which plain ERC-721 lacks and which is per-id for ERC-1155. Deliberate: a reserve cap on a fungible supply has no `tokenId` dimension. Recorded in the overload matrix in `RULE_SEMANTICS.md`.
+ - **Documented:** one instance protects one token. The rule reads `totalSupply()` from its configured `tokenContract` rather than from the token that triggered the check, and has no binding to enforce the pairing — sharing an instance across RuleEngines silently evaluates both tokens against the first one's supply and feed. Same exposure as `RuleMaxTotalSupply`; see [One instance per protected token](./doc/technical/contracts/RuleChainlinkPoR.md#one-instance-per-protected-token).
+- **`RuleReceiverWhitelist`** — a whitelist that screens **only the receiver**, reproducing ERC-3643's eligibility rule as a CMTAT compliance rule. It fills the gap between `RuleWhitelist` (both parties) and `RuleSpenderWhitelist` (spender only). Restriction code `81`. Available as `RuleReceiverWhitelist` (AccessControl) and `RuleReceiverWhitelistOwnable2Step`.
+ - The sender and the spender are **never** screened. That is the point, not an omission: screening the sender traps de-listed holders, and ERC-3643 checks only the receiver precisely so a lapsed investor can still exit their position.
+ - Mint is screened on the receiver like any other transfer — no `allowMint`/`allowBurn` flags, since ERC-3643 gates minting on receiver eligibility alone. Compose with `RuleMaxTotalSupply` or `RuleChainlinkPoR` to cap issuance.
+ - Burn (`to == address(0)`) is exempt explicitly, because the zero address can never be listed and every burn would otherwise be rejected.
+ - Equivalence with the standard is pinned against the **real vendored ERC-3643 token** in `test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol`: the rule runs in the token's compliance slot over the same address set the identity registry holds, and must never change the outcome.
+- `AggregatorV3Interface` and `IDecimals` in `src/rules/interfaces/`, so the library reads Chainlink feeds without taking a dependency on the Chainlink contracts package.
+- **`IdentityRegistryWhitelist`** — a whitelist that plugs into an **ERC-3643 token's identity registry slot** (`token.setIdentityRegistry(...)`), so a token can enforce investor eligibility without deploying ONCHAINID contracts. `registerIdentity` whitelists, `deleteIdentity` removes, `isVerified` answers the token's per-transfer check. Only the subset of `IIdentityRegistry` that `Token.sol` actually calls is implemented. This is **not** a compliance rule: no `IRule` surface, and it must not be added to a `RuleEngine`.
+ - Implements **no** ERC-734 surface. `recoveryAddress` must be given a real ONCHAINID as `_investorOnchainID`; the registry only supplies `isVerified`, `registerIdentity`, `deleteIdentity` and `investorCountry`. Consequently `registerIdentity` rejects duplicates exactly like the reference registry, and the replacement wallet is registered by the token during recovery rather than beforehand — no behavioural divergence from stock ERC-3643 remains.
+ - The ERC-3643 token must hold `IDENTITY_REGISTRAR_ROLE`, because `recoveryAddress` makes the token call `registerIdentity` and `deleteIdentity`.
+ - Reuses `RuleAddressSetInternal` — the same `EnumerableSet` machinery as `RuleWhitelist` / `RuleBlacklist` — for storage, the zero-address guard and the revert errors, so no whitelist contract is deployed and none is re-implemented. Only the internal layer is inherited, so the registry exposes one write API rather than two overlapping ones.
+ - **No identity data is kept.** The `_identity` and `_country` arguments exist so the ERC-3643 signature matches, then are discarded; `investorCountry` is a constant `0`. The contract is a wrapper that adapts the token's registry calls onto a plain whitelist. `Token.sol` reads the country in exactly one place (`recoveryAddress`, a pass-through it hands straight back), so the token is unaffected; the exposure is a *custom* compliance module reading `investorCountry`, which would see every investor as country 0.
+ - Implements `IERC3643Version` via `VersionModule`, like the rules: it is a deployable production contract wired into a token's identity slot, so its release must be identifiable on-chain.
+ - `isVerified(address(0))` is always `false`; the zero address can never be registered.
+
+### Changed
+
+- **`ADDRESS_LIST_ADD_ROLE` / `ADDRESS_LIST_REMOVE_ROLE` moved** from `RuleAddressSetInvariantStorage` into a new `RuleAddressSetRolesStorage`, inherited by `RuleAddressSet` — the public layer that actually enforces them. No rule's ABI changes: `RuleWhitelist`, `RuleReceiverWhitelist`, `RuleBlacklist` and `RuleSpenderWhitelist` still expose both. The effect is on `IdentityRegistryWhitelist`, which reuses only the internal layer and was publishing both roles while gating registration on `IDENTITY_REGISTRAR_ROLE` — an operator could grant one, receive no privilege, and get no signal that the grant was inert. They are now absent from its ABI, pinned by `testDoesNotExposeInertAddressListRoles`.
+
+- **`RuleMaxTotalSupply` now validates its token contract and guards the supply read** (same hardening as `RuleChainlinkPoR`, threat `EXT-4`). The constructor and `setTokenContract` reject a non-contract address (`RuleMaxTotalSupply_TokenIsNotAContract`) and probe that `totalSupply()` is callable (`RuleMaxTotalSupply_TokenTotalSupplyUnavailable`). At run time a token whose `totalSupply()` reverts yields the new restriction code `51` (`CODE_SUPPLY_ORACLE_UNAVAILABLE`) instead of reverting the ERC-1404 / ERC-3643 views, which MUST NOT revert. Codeless targets are excluded by construction rather than by a runtime check: the setters require code and EIP-6780 (Cancun) makes that permanent, recorded as a deployment precondition in each rule doc.
+ - **Migration:** deployments that passed a placeholder or non-contract address as `tokenContract` now revert at construction. This only rejects configurations that could never have worked — the previous behaviour was to accept them and then revert on every restriction check. Integrators switching on restriction codes should handle `51`, which can only appear where the view previously threw.
+
+- **Batch events now report what changed, not just what was submitted** (`CLAUDE_ANALYSIS.md` C-4). Every batch add/remove loop already computed `added` / `removed` and `skipped`, and every caller discarded them, emitting only the input array — so a batch of 100 new members and a batch of 100 no-ops produced byte-identical events. The counters are now carried by the event; `added + skipped` always equals the input length.
+ - **Breaking change to the event ABI.** Six signatures gain two `uint256` fields — `AddAddresses` / `RemoveAddresses` on `IAddressList`, and the four `RuleERC2980` whitelist/frozenlist equivalents — so their `topic0` changes: `AddAddresses(address[])` was `0xc81f47d2…`, `AddAddresses(address[],uint256,uint256)` is `0x986167d3…`. An indexer filtering on the old topic stops matching. Landing it in v0.5.0, before release, is deliberate: after release this would be a major-version discussion.
+ - Costs **+572 gas per batch call** — two extra 32-byte log words, **constant rather than per element**: 0.06% of a 20-address add (dominated by cold `SSTORE`s), 1.0% of the cheaper remove. Nothing new is spent in the loop, since the counters were already being computed.
+- **`VersionModule.version()` is now `pure` instead of `view`** (`CLAUDE_ANALYSIS.md` F-7b). It returns a compile-time constant and reads no state, so `pure` is the accurate mutability; Solidity permits an override to tighten it, and `AggregatorV3Mock.version()` already used the same `pure`-over-`view` pattern. **This changes the ABI's `stateMutability` field** for `version()` on every deployable contract — the selector is unchanged, no other ABI entry differs, and both mutabilities are read-only, so callers are unaffected; only a release-to-release ABI diff will show it.
+- **Removed an empty `INTERNAL FUNCTIONS` banner** from `IdentityRegistryWhitelistBase` (`CLAUDE_ANALYSIS.md` F-7a). The contract's only internal member sits under the `ACCESS CONTROL` banner; the empty section invited the next internal function to be filed in the wrong place.
+- **Address-set writes no longer look the entry up twice** (`CLAUDE_ANALYSIS.md` B-4). `addAddress`, `removeAddress`, the four `RuleERC2980` list writes and `registerIdentity` / `deleteIdentity` each tested membership with `contains()` and then called `add()` / `remove()`, which performs the same test internally and returned a result that was discarded. The six internal helpers now forward `EnumerableSet`'s result and the callers `require` on it, so the guard and the mutation are the same lookup and cannot disagree.
+ - Behaviour and error identity are unchanged: `add` returns `false` for a duplicate without touching storage, and the zero-address guard still runs first, so `address(0)` still yields `ZeroAddressNotAllowed` rather than the duplicate error.
+ - Saves **~288 gas** per call, measured across all five affected entrypoints (0.4% of an add, which is dominated by the cold `SSTORE`; ~7% of a remove).
+- **The `checkSpender` setter machinery lives in `RuleWhitelistShared` instead of being duplicated in both whitelist bases** (`CLAUDE_ANALYSIS.md` D-4). `setCheckSpender`, `_setCheckSpender`, `_authorizeCheckSpenderManager` and the `onlyCheckSpenderManager` modifier were verbatim identical in `RuleWhitelistBase` and `RuleWhitelistWrapperBase`, although the `checkSpender` flag itself already lived in their shared parent. Each now sits beside its `allowMint`/`allowBurn` counterpart, which was already organised this way.
+ - **No ABI change anywhere.** Hoisting a public function into a shared parent adds it to every inheriting contract, so this was verified rather than assumed: the function-level ABI is byte-identical for `RuleWhitelist`, `RuleWhitelistOwnable2Step`, `RuleWhitelistWrapper`, `RuleWhitelistWrapperOwnable2Step`, `RuleReceiverWhitelist`, `RuleSpenderWhitelist` and `RuleBlacklist`. The receiver- and spender-whitelist rules build on `RuleNFTAdapter` directly and did not gain `setCheckSpender` — which would have been wrong for `RuleReceiverWhitelist`, whose whole design is to screen only the receiver. Storage layout unchanged.
+ - `RuleIdentityRegistry.setCheckSpender` is untouched and stays separate: a different flag, a different manager role, a different event, and a different meaning ("also require the spender to be identity-verified").
+ - Line coverage of both bases rose to 100% — the previously-uncovered lines were the duplicated abstract declarations.
+- **`RuleWhitelistWrapper.isVerified` delegates to `_isListedInAnyChild` instead of repeating its body** (`CLAUDE_ANALYSIS.md` D-5). The two functions were identical, 90 lines apart. Behaviour unchanged, and the ERC-3643 eligibility view now resolves an address through the same helper the mint and burn branches of `_detectTransferRestriction` use — so the view and the transfer check cannot disagree by construction rather than by coincidence. Runtime bytecode drops **124 bytes** (~24,800 gas at deployment); each `isVerified` call costs 27 gas more for the internal call.
+- **`RuleMaxTotalSupplyBase` and `RuleMaxBalanceBase` are split the same way**, completing the pattern started with `ChainlinkPoRFeedManager`. New `TotalSupplyCapManager` and `BalanceCapManager` in `abstract/core/` hold the observed token, the cap, the exemption list (for the balance rule), the setters and the revert-free read; each base keeps its constructor, the ERC-1404 / ERC-3643 surface and the logic that turns a breached cap into a restriction code.
+ - **Neither manager declares a constructor or depends on ERC-1404.** They answer in booleans (`_capExceeded`) and token units (`_remainingCapacity`); the rule owns the code mapping. So *when* configuration happens is the inheritor's choice — a constructor today, an initializer in an upgradeable variant — and a contract wanting only a revert-free view of remaining headroom can inherit one without implementing a restriction-code surface.
+ - **Storage layout and ABI are unchanged**, verified per-slot from the compiled artifacts for all four deployable contracts (`RuleMaxTotalSupply`, `RuleMaxTotalSupplyOwnable2Step`, `RuleMaxBalance`, `RuleMaxBalanceOwnable2Step`). `RuleMaxBalance.remainingCapacity` keeps its public `(uint8 restrictionCode, uint256 headroom)` signature: the manager's version is `internal` and code-free, and the rule wraps it, so no ABI entry moved.
+ - The two bases drop to 83 and 88 code lines. All 820 + 31 tests pass unchanged; the style checker is clean.
+- **`RuleChainlinkPoRBase` is split in two.** Proof-of-Reserve feed management moved to a new `ChainlinkPoRFeedManager` in `abstract/core/`, which `RuleChainlinkPoRBase` now inherits. The manager holds the feed, the protected token, `tokenDecimals`, `maxStalenessSeconds`, their setters, and the revert-free reserve read; the base keeps the constructor, the ERC-1404 / ERC-3643 surface and the restriction logic.
+ - **It declares no constructor and no ERC-1404 dependency**, which is the reason for the split: *when* configuration happens is left to the inheritor (a constructor today, an initializer in an upgradeable variant), and a contract that only wants a revert-free view of reserve-backed supply can inherit it without acquiring a restriction-code surface it would have to implement.
+ - **Storage layout and ABI are unchanged**, verified per-slot from the compiled artifacts for both `RuleChainlinkPoR` and `RuleChainlinkPoROwnable2Step` — the six slots and all 35 / 31 ABI functions are identical. Behaviour, gas and coverage are unchanged; all 763 + 31 tests pass.
+- **`RuleMaxTotalSupply` and `RuleChainlinkPoR` share their supply-reading mechanics** (`CLAUDE_ANALYSIS.md` D-2). New `TokenSupplyReader` holds the revert-free `totalSupply()` read that both rules implemented byte-identically, plus the `try/catch` probe their configuration validators use. Behaviour unchanged; **storage layout identical**, verified per-slot from the compiled artifacts for both rules and both Ownable2Step variants.
+ - **The base declares no storage.** Each rule keeps its own `tokenContract` and implements a `_supplyToken()` hook — the template-method pattern already used for `_authorize*`. Declaring the variable in the base would have reordered `RuleChainlinkPoR`'s slots, moving `tokenContract` ahead of `reservesFeed`, for no benefit.
+ - **Configuration validation stays per-rule.** Both rules check non-zero / has-code / `totalSupply()`-callable, but each raises its own named error for each failure. Only the probe moved, returning a `bool` the rule turns into its own error; collapsing the three `require`s into one helper would trade three named operator-facing diagnostics for a few saved lines.
+ - Marginally **faster**: 12 gas less on both mint read paths (`RuleChainlinkPoR` 5,966 → 5,954, `RuleMaxTotalSupply` 2,460 → 2,448), because the hook inlines and removes an intermediate stack shuffle.
+- **The batch add/remove loops are shared instead of written three times** (`CLAUDE_ANALYSIS.md` D-1). New `AddressSetBatchLib` holds the two loops that `RuleAddressSetInternal` and `RuleERC2980Internal` (once for its whitelist, once for its frozenlist) each carried their own copy of. Behaviour is unchanged and **storage layout is identical**, verified per-slot from the compiled artifacts across `RuleWhitelist`, `RuleERC2980`, `RuleBlacklist`, `RuleReceiverWhitelist`, `RuleSpenderWhitelist` and `IdentityRegistryWhitelist`.
+ - Only the loops moved. Single-address `add` / `remove` / `contains` / `length` stay as one-line delegations to `EnumerableSet`, where a library would add indirection without removing duplication.
+ - **Each rule keeps its own zero-address error.** `RuleAddressSet_ZeroAddressNotAllowed` and `RuleERC2980_ZeroAddressNotAllowed` are distinct per the one-error-namespace-per-rule convention, which a shared loop cannot name. The guard is therefore passed to `addBatch` as an `internal pure` function pointer: a required parameter, so it cannot be forgotten, while the revert data each rule produces is unchanged.
+ - Costs ~34 gas per entry on a batch add and ~4 on a batch remove, from the indirect jump — 0.07% of a 20-address batch, which is dominated by cold `SSTORE`s, and an operator path rather than a per-transfer holder path. `RuleERC2980`'s runtime bytecode shrinks 200 bytes; `RuleWhitelist`'s grows 62.
+- **`RuleWhitelistWrapper`: the child-rule scan's early exit is now O(1) instead of a full rescan** (`CLAUDE_ANALYSIS.md` A-2). `_detectTransferRestrictionForTargets` used to re-derive "have all targets been resolved?" by walking the whole `result` array after every child rule; it now maintains a counter of unresolved targets and breaks when it reaches zero. Behaviour is identical — including the documented consequence that a pair already resolved by an earlier child never reaches a later, broken child (documented wrapper behaviour WW-2). Saves ~85 gas per child scanned (~1% of the ~8.8k per-child cost, which is dominated by the external `STATICCALL`); ~850 gas on a rejected transfer through a 10-child wrapper, paid by the transferring user.
+ - The resolved-counter form needs a `!result[j]` guard so an address listed in several children is counted once. Without it the counter would reach zero early and break out of the scan before a later child could resolve a *different* target, rejecting a valid transfer. Pinned by `testDetectTransferRestrictionOkWhenAddressListedInSeveralChildRules`, which covers both the 2-target and the `checkSpender` 3-target paths.
+
+- **Conditional-transfer rules: the approval counter is no longer re-read from storage to populate `TransferApproved`** (`CLAUDE_ANALYSIS.md` B-1). `approveTransfer` (`RuleConditionalTransferLightApprovalBase`) and `_approveTransfer` (`RuleConditionalTransferLightMultiTokenBase`) computed `approvalCounts[hash] += 1` and then read the slot back for the event; they now keep the new value in a local. Event data is byte-identical. Saves ~109 gas per approval, measured with the variants isolated in single-function contracts so dispatch cost is held constant.
+ - The four `= count - 1` sites named in the same finding were deliberately **left as they are**. Measurement showed the optimizer already forwards the stored value to the subsequent load there, so the re-read is free and adding an explicit local costs ~12 gas. Changing all six gave back roughly two thirds of the saving: on `test_CTL2_EngineKeyedApprovalIsSharedAcrossTokens` the blanket change saved 906 gas against baseline, the increment-only change saves 2,899.
+
+- **`RuleSanctionsList`: the oracle address is read from storage once per check instead of up to five times** (`CLAUDE_ANALYSIS.md` B-2). `_detectTransferRestriction` and `_detectTransferRestrictionFrom` re-read `sanctionsList` for the zero-address guard and again for each `isSanctioned` call; they now cache it in a local. Behaviour is unchanged and the caching is provably safe: both functions are `view`, so the oracle is reached by `STATICCALL` and cannot write this contract's storage. Measured saving on the paths a compliant transfer takes: **219 gas** on `detectTransferRestriction` and **320 gas** on `detectTransferRestrictionFrom` (~98 gas per avoided warm `SLOAD`); 98 gas on the early-return sanctioned paths, 7 gas when no oracle is configured.
+ - The reload inside the `transferFrom` → direct-check delegation was left in place on purpose. A private `_screen(oracle, from, to)` helper would remove another ~100 gas, but `_detectTransferRestrictionFrom` calling `_detectTransferRestriction` is how every rule in the library composes its two checks; bypassing it would mean an override of the direct hook no longer applied to `transferFrom`.
+
+- **All `internal` functions are now `virtual`, as the project convention requires** (`CLAUDE_ANALYSIS.md` E-1). Sixteen were not: `_authorizeTransferExecution` (`RuleConditionalTransferLightBase`), the batch set helpers in `RuleAddressSetInternal` and `RuleERC2980Internal`, and the `_detectTransferRestriction` / `_detectTransferRestrictionFrom` pair on `RuleBlacklist`, `RuleIdentityRegistry`, `RuleMaxTotalSupply`, `RuleChainlinkPoR` and `RuleSanctionsList` (direct hook only). A re-scan reports zero remaining. No ABI change and **no gas change** — Solidity resolves `internal virtual` calls statically through the C3 linearization, verified by three `ThreatModel` tests reporting gas identical to the last digit before and after.
+ - `_authorizeTransferExecution` is the one that mattered most: it is the hook the conventions single out as required to be `internal view virtual`, and without it no subclass could change who may consume an approved transfer.
+ - The `_detectTransferRestriction*` hooks were previously `virtual` on the whitelist family and not on the blacklist and oracle-backed rules, with `RuleSanctionsListBase` declaring the direct hook non-`virtual` and its `From` sibling `virtual` 28 lines apart. That split is now gone.
+
+- **Both `canTransfer` overloads on the core rule bases are now `virtual`** (`CLAUDE_ANALYSIS.md` E-2). `RuleTransferValidation.canTransfer(from, to, amount)` and its ERC-7943 twin `RuleNFTAdapter.canTransfer(from, to, tokenId, amount)` were each the only non-`virtual` function in their contract — every neighbouring view (`detectTransferRestriction`, `detectTransferRestrictionFrom`, `canTransferFrom`, `supportsInterface`, the `transferred` overloads) was already `virtual` — so no rule could override the view integrators reach for first. No ABI change, no gas change.
+ - This does **not** cover the roughly 55 other non-`virtual` public views across the library (`messageForTransferRestriction`, `canReturnTransferRestrictionCode`, the `RuleERC2980` getters, the `RuleAddressSet` read surface, the conditional-transfer read surface, and others). Those break no local pattern and are a codebase-wide sweep to be decided alongside the public mutating functions.
+
+- **The 27 public mutating functions that were not `virtual` now are** (`CLAUDE_ANALYSIS.md` E-3): the four `RuleAddressSet` write functions, the eight `RuleERC2980Base` list functions, `setMaxTotalSupply` / `setTokenContract`, `setIdentityRegistry` / `clearIdentityRegistry`, and the approval, binding, `approveAndTransferIfAllowed` and `transferred` entrypoints on both conditional-transfer rules. A re-scan returns zero remaining. No ABI change and no gas change.
+ - This removes three inconsistencies the finding called out: `RuleMaxTotalSupply`'s setters were non-`virtual` while `RuleChainlinkPoR`'s equivalents were `virtual`; `resetApproval` was `virtual` while `approveTransfer` and `cancelTransferApproval` beside it were not; and the four token-facing `transferred` hooks — the functions a token calls on every transfer — were the least overridable in the library.
+ - **`IdentityRegistryWhitelist` is deliberately not refactored.** `CLAUDE.md` justifies its inheriting only `RuleAddressSetInternal` partly on `addAddress`/`removeAddress` not being `virtual`, which is no longer true, but the independent reason recorded in its technical doc — exposing exactly one write API rather than two overlapping ones — still holds. The agent-guide wording is now stale and should be corrected separately.
+
+- **`RuleSanctionsList`: the `transferFrom` path now always consults the direct restriction check** (`CLAUDE_ANALYSIS.md` F-2). `_detectTransferRestrictionFrom` nested its delegation to `_detectTransferRestriction` inside the `oracle != address(0)` branch, so with no oracle configured it returned `TRANSFER_OK` without ever calling the hook. The oracle guard now scopes only the spender check and the delegation is the unconditional last statement, matching `RuleBlacklist`, `RuleWhitelist` and `RuleIdentityRegistry`.
+ - Behaviour is unchanged for the rule as shipped, but the defect was reachable: the `_detectTransferRestriction` hook became `virtual` in this release, so a subclass adding an oracle-independent check would have had it applied to `transfer` and silently **not** to `transferFrom` whenever no oracle was set — a rule screening one entrypoint but not the other.
+ - Costs **221 gas** on the `transferFrom` path when no oracle is configured (1,547 → 1,768), since that path now re-reads the oracle slot inside the delegated hook rather than returning early. Paths that actually screen are unchanged within noise, and a plain `transfer` is identical.
+- **`RuleSanctionsList` no longer asks the oracle whether `address(0)` is sanctioned** (`CLAUDE_ANALYSIS.md` F-1). The zero address is the ERC-20 mint/burn sentinel, not a wallet: `from` is now skipped on a mint and `to` on a burn, matching how every other rule in the library treats it. Previously the sentinel was forwarded to the oracle on every issuance and redemption, so an oracle that answered `true` for `address(0)` — a degenerate input it is free to answer either way — would have blocked **all minting and all burning** on every token using this rule, reporting a "sanctioned sender" that is not an address. Chainalysis returns `false` today; the rule no longer depends on that.
+ - **Screening of real participants is unchanged.** A mint to a sanctioned recipient is still rejected with code `31`, a burn from a sanctioned holder with code `30`, and the **minter is still screened as the `spender`** on the 4-argument mint path.
+ - Side effect: a mint or burn now makes one oracle call instead of two, saving **2,830 gas** per issuance and redemption (5,308 → 2,478). The saving exceeds one call's nominal cost because the removed call read `address(0)`'s slot in the oracle, which nothing else ever touches and is therefore **cold on every mint**. A plain transfer pays **+96 gas** for the two new guards, on a path where they are always true.
+
+- **`RuleIdentityRegistry`: removed a dead `to != address(0)` term from the spender check** (`CLAUDE_ANALYSIS.md` F-3). The burn guard six lines above already returns when `to` is the zero address, so the term could never be false. Behaviour is identical. The comment above it was wrong in the same way — it read *"Mint (from == 0) and burn (to == 0) are exempt"*, crediting this condition with a burn exemption the early return actually provides; it now states where burn is really handled and warns against re-adding the test. Saves **49 gas** on a `transferFrom` with both opt-in flags enabled and 20 gas on the receiver-only default; the burn path is unchanged to the gas, which is itself evidence that burn never reaches this condition. Already pinned by `testBurnBypassesAllChecks`, so no new test.
+- **`RuleIdentityRegistry`: the registry address is read from storage once per check instead of up to five times** (`CLAUDE_ANALYSIS.md` B-3), the same treatment as `RuleSanctionsList` above and safe for the same reason — both functions are `view`, so `isVerified` is reached by `STATICCALL` and cannot write `identityRegistry`. Behaviour unchanged. Measured: **113 gas** saved on a receiver-only transfer (the ERC-3643 default) and on a mint, **219** with `checkSender` enabled, **320** on a `transferFrom` with both flags on.
+ - The path where no registry is configured is **5 gas more expensive** — loading the slot into a typed local before comparing costs a couple of stack operations. Accepted: that is the path where the rule is switched off and does nothing, against 108–320 gas saved wherever it actually screens.
+ - The duplicated null-registry and burn guards between `_detectTransferRestrictionFrom` and `_detectTransferRestriction` were deliberately left in place, as with `RuleSanctionsList`: collapsing them needs a helper that takes the registry as a parameter, which would stop a subclass's override of the direct hook from applying to `transferFrom`.
+
+- **Deployment-time configuration is now announced by every rule** (`CLAUDE_ANALYSIS.md` C-1, C-2, C-3). Three constructors assigned configuration silently, so a rule set up once at deployment and never reconfigured had no on-chain event trail for those values — an indexer saw the setting appear from nowhere at the first later change, or never at all.
+ - **`RuleMaxTotalSupply`** emitted neither `TokenContractUpdated` nor `MaxTotalSupplyUpdated` at construction, although both setters did and its sibling `RuleChainlinkPoR` announced all three of its values. The constructor now routes through new internal `_setTokenContract` / `_setMaxTotalSupply` helpers that the public setters also use, matching the `RuleChainlinkPoR` pattern.
+ - **`RuleWhitelist` and `RuleWhitelistWrapper`** emitted `AllowMintUpdated` and `AllowBurnUpdated` at construction but not `CheckSpenderUpdated`, on adjacent lines. The emit moved from the public setter into `_setCheckSpender`, which the constructors now call — so the flag is announced on every assignment, exactly like `_setAllowMintBurn`. The public setter still emits precisely once.
+ - **`RuleIdentityRegistry`** emitted both check flags at construction but not `IdentityRegistryUpdated` for the registry address the rule is parameterised by. It now does, **only when a registry is actually assigned**: a zero argument leaves the default untouched, and an `IdentityRegistryUpdated(0)` there would be indistinguishable from a deliberate `clearIdentityRegistry()`. This matches `RuleSanctionsListBase`, which already emitted only when an oracle was supplied.
+ - No ABI change; the rule for the whole library is now "every value actually assigned is announced".
+
+- **Style-guide pass over the new code.** `_authorizeMaxBalanceManager` moved below the internal setters in `RuleMaxBalanceBase`, so the `view`-last ordering matches `ChainlinkPoRFeedManager` and every other rule; `BalanceOfMock` gained NatSpec on its storage and replaced its string revert with a `BalanceOfMock_Reverting()` custom error. Behaviour-preserving: one member block relocated with its NatSpec, one comment added, one revert reason swapped for an equivalent error nothing asserts on.
+
+### Changed — dependencies
+
+- **Solidity toolchain updated to `0.8.36`** (from `0.8.34`), in both `foundry.toml` and `hardhat.config.js`. Builds clean and all three suites pass unchanged: 763 Foundry tests, 31 on the ERC-3643 profile, and the Hardhat smoke test.
+ - **`[profile.erc3643]` deliberately stays on `0.8.30`**, and must. The vendored ERC-3643 `Token.sol` pins `pragma solidity 0.8.30` *exactly*, so it cannot share a compilation unit with the default profile at any other version. Verified after the bump: the default profile compiles with Solc 0.8.36, the ERC-3643 profile with Solc 0.8.30.
+ - Source pragmas are untouched. Contracts stay on `^0.8.20` so integrators pin their own compiler; only this repository's own builds move.
+ - The v0.5.0 Slither and Aderyn reports were produced at `0.8.34` and record that in their headers. They were not re-run for a patch-level compiler bump; their findings are source-level (pragma, PUSH0, centralization, empty blocks) and do not depend on the codegen version.
+
+- **RuleEngine bumped to `v3.0.0-rc5` and OpenZeppelin to `v5.7.0`** (from `v3.0.0-rc4` and `v5.6.1`, both `openzeppelin-contracts` and `-upgradeable`). **These two are coupled and must move together**: rc5 changed `ERC3643ComplianceModule.getTokenBound()` from `_boundTokens.at(0)` to `_boundTokens.pos(0)`, and `pos` does not exist before OpenZeppelin `v5.7.0`. Building rc5 against `v5.6.1` fails with `Member "pos" not found ... in struct EnumerableSet.AddressSet`.
+ - **No behaviour change.** OpenZeppelin documents `pos` as "Replacement of the deprecated `at` function", so `getTokenBound()` returns what it always did. No interface this library implements changed: the diff across RuleEngine's `src/` is comments, NatSpec and library constants.
+ - `at` is now deprecated in `EnumerableSet` but still present. This library never calls it — the address-set machinery uses `add` / `remove` / `contains` / `length` / `values` — so nothing here needs migrating.
+ - All suites pass unchanged on the new pins: 763 Foundry tests, 31 on the ERC-3643 profile, the Hardhat smoke test, and all four deployment scripts under `forge script`.
+
+- **CMTAT submodule bumped to `v3.3.0-rc3`** (from `v3.3.0-rc1`). Builds clean and both test profiles pass unchanged (763 + 31). No interface this library imports changed shape: `ICMTATConstructor`, `IRuleEngine` and `draft-IERC1643CMTAT` differ only in the pragma and NatSpec, and the validation modules changed in comments only.
+ - `IRuleEngine` gains a **normative requirement** in its NatSpec: zero-value calls are permissionless, because ERC-20 treats a `0` transfer as a normal transfer and `_spendAllowance` consumes no allowance, so anyone can reach `transferred(spender, from, to, 0)` for an arbitrary `from`. Implementations "MUST therefore treat `value == 0` as carrying no economic meaning: any stateful rule ... MUST be a no-op for a zero value". **The stateful rules in this library do not yet satisfy this, and that is a recorded decision for `v0.5.0` rather than an oversight** — the fix is a behaviour change to shipped compliance logic (a zero-value transfer would stop reverting) and was deferred rather than applied late in the release. The scope was established by measurement, not by reading the requirement: `RuleConditionalTransferLight` and `…MultiToken` consume an approval for a caller never approved, and `RuleMaxBalance` rejects a receiver already over the cap — both would change. `RuleMintAllowance` needs **no** change (debiting `0` is already a no-op and the mint path is not permissionlessly reachable), and the address-screening rules are deliberately **out** of scope, since they screen *who* rather than *how much* and a blanket zero-value exemption would weaken every deny-list. Documented in `doc/technical/contracts/RuleConditionalTransferLight.md` and `doc/technical/contracts/RuleMaxBalance.md`.
+
+### Fixed
+
+- **CI now runs the ERC-3643 test suite.** `.github/workflows/test.yml` ran only `forge test`, which uses the default profile — and `test/ERC3643Real/**` is in that profile's `skip` list. The 18 tests built by `[profile.erc3643]`, including the parity suite that runs against the real vendored `Token.sol`, were therefore never executed in CI, despite `AGENTS.md` / `CLAUDE.md` stating that both commands are required. Added a `Run Forge tests (ERC-3643 profile)` step with a step-level `FOUNDRY_PROFILE: erc3643`, which overrides the workflow-level `ci` for that step only.
+- **CI: `npx hardhat test` no longer fails on the ERC-3643 remapping.** The context-scoped remapping `lib/ERC-3643/:@onchain-id/solidity/contracts/=test/utils/onchainid/` moved out of `remappings.txt` and into `remappings = [...]` under `[profile.erc3643]` in `foundry.toml`. `forge remappings` prints `remappings.txt` for every profile, and `hardhat-foundry` runs exactly that command and rejects any line containing a `:` — `HardhatFoundryError: Invalid remapping ..., remapping contexts are not allowed` — which aborted the workflow's last step. Declared as profile config the remapping applies only when that profile is selected, so the default and `ci` profiles Hardhat sees are context-free while `FOUNDRY_PROFILE=erc3643` still resolves the ONCHAINID stubs. No Solidity changed and no build output moved; `forge test` (691) and `FOUNDRY_PROFILE=erc3643 forge test` (18) are unaffected.
+
+### Documentation
+
+- **NatSpec length ceiling, and a pass to meet it.** `CLAUDE.md` / `AGENTS.md` gain a convention capping a NatSpec block at 20 lines, with the rationale that a comment past that has stopped being a comment and become a document, and that the more claims a block makes the more of them go stale unnoticed. The ceiling was set from the measured distribution over `src/` rather than picked: median 4 lines, 90th percentile 8. Fifteen blocks exceeded it and were rewritten to keep the safety preconditions, footguns and non-obvious design constraints while cutting code restatement, refactor narration and benefit lists — the distribution now runs median 4 / p90 8 / **max 19** across 1003 blocks, with none at or over the ceiling. Comment-only: filtering the whole diff for non-comment lines yields nothing, so no behaviour, ABI or storage change is possible; 820 + 31 tests pass unchanged. The `analyse-code-quality` skill gained the corresponding check.
+ - One block was damaged by the trimming and repaired in a follow-up: `BalanceCapManager`'s header lost the middle of its first `@dev`, leaving `Declares **no constructor**` with no verb or period glued to an unrelated sentence. Found by inspecting the compiled `devdoc` rather than the source. **The line-count check cannot catch this** — it measures length, not whether a trimmed sentence still parses as English — so the other fourteen rewrites were re-read by hand and are clean.
+ - Worth knowing for anyone editing these blocks: **solc merges repeated `@dev` tags into one `details` string with no separator**, so `…the cap.` followed by a new `@dev` renders as `…the cap.{_balanceOf} must never revert`. Within a single tag, lines join with a space and blank lines collapse. Multiple `@dev` is valid and loses no content — 15 blocks in `src/` use it — but paragraph structure survives only in the source, not in `forge doc` or Etherscan output.
+- New [`doc/technical/contracts/RuleReceiverWhitelist.md`](./doc/technical/contracts/RuleReceiverWhitelist.md), including why receiver-only screening is the conformant choice and how the parity suite tests it.
+- New [`doc/technical/contracts/IdentityRegistryWhitelist.md`](./doc/technical/contracts/IdentityRegistryWhitelist.md), including a table of which ERC-3643 token functions call the registry and how, the `recoveryAddress` call sequence, and five documented limitations.
+- New [`doc/technical/contracts/RuleChainlinkPoR.md`](./doc/technical/contracts/RuleChainlinkPoR.md), including a point-by-point comparison against Chainlink's `SecureMintPolicy 1.2.0` (vendored at `lib/chainlink-ace/`) and a *Token compatibility: ERC-20 only* section; `RULE_SEMANTICS.md` and `README.md` updated with the new rule.
+- **README ERC-721/ERC-1155 section corrected.** It listed only `RuleConditionalTransferLight` and `RuleMaxTotalSupply` as ERC-20 only, and described `IERC7943NonFungibleCompliance*` as "implemented by validation rules only" — a category claim that `RuleChainlinkPoR`, itself a validation rule, does not satisfy, so the README implied the opposite of the truth for the new rule. The exclusions are now named individually and the missing `totalSupply()` requirement is stated. The `ITransferContext` paragraph had the same defect and claimed `RuleMaxTotalSupply` exposes the fungible variant, which it does not — it exposes neither, as do `RuleChainlinkPoR` and `RuleMintAllowance`. `RULE_SEMANTICS.md` already had the correct matrix row for all of them; the README sections now link to it.
+
+- **`RuleMintAllowance`'s non-authoritative views are documented one level up** (`CLAUDE_ANALYSIS.md` F-6). `canTransfer` / `detectTransferRestriction` are hardcoded to "allowed" because the 3-argument signature carries no minter identity — already documented for the rule itself (`CLAUDE_AUDIT.md` F-7). What was missing is that the answer **propagates**: `RuleEngineBase` aggregates by calling each rule's 3-argument view, and CMTAT's `ValidationModuleERC1404` forwards the token's ERC-1404 views to the engine, so `ruleEngine.canTransfer` and `cmtat.canTransfer` report a mint as allowed that then reverts. The token is the address integrators actually call, so the audience most likely to be misled was the one furthest from the existing warnings.
+ - `doc/technical/contracts/RuleMintAllowance.md` gains a per-entrypoint table covering the engine and token levels, and a callout naming `cmtat.detectTransferRestrictionFrom(minter, address(0), to, value)` as the authoritative pre-flight for anyone holding only the token address.
+ - `README.md` gains a *Views that are not authoritative* section covering `RuleMintAllowance` and `RuleConditionalTransferLightMultiToken` together, with the propagation mechanism and why returning a restriction code instead would be worse — ERC-1404 has no "cannot answer" value, so the token would report every mint as forbidden, including those that will succeed.
+ - **Behaviour unchanged; no Solidity modified.**
+- **The approval-key preimage is documented, and the comment that described it wrongly is fixed** (`CLAUDE_ANALYSIS.md` F-4). `_transferHash` hashes a project-specific encoding — 32-byte words with each address **left**-aligned and right-padded — which is neither `abi.encodePacked` (72 bytes, unpadded) nor `abi.encode` (96 bytes, right-aligned). The inline comment said "hash packed values", pointing anyone reimplementing the key off-chain at the wrong encoding; because the result is a mapping key the mistake is **silent**, reading `0` and looking exactly like "no approval exists".
+ - NatSpec on both rules now gives the word-by-word layout, the warning, and two formulations that reproduce the key: `keccak256(abi.encodePacked(from, bytes12(0), to, bytes12(0), value))` and `keccak256(abi.encode(bytes32(bytes20(from)), bytes32(bytes20(to)), value))`. The multi-token variant is the same shape with `token` prepended (128 bytes).
+ - It also points readers at `approvedCount`, which resolves `(from, to, value)` directly — the hash is only needed to derive the storage slot for `eth_getStorageAt`, a state proof, or an indexer reading storage rather than events.
+ - **The assembly is unchanged.** It is on the transfer write path and ~109 gas cheaper per call than `abi.encodePacked` (1,032 vs 1,141, measured with each variant in its own single-function contract), its injectivity is verified in `CLAUDE_AUDIT.md` F-12, and switching encodings would change every storage key — orphaning outstanding approvals in any deployed instance.
+- **The batch-operation convention is documented accurately** (`CLAUDE_ANALYSIS.md` F-5). Every add path in the library rejects `address(0)`, batch included, but the documentation said the opposite in three places: `CLAUDE.md` / `AGENTS.md` invariant I-12 claimed "single adds revert, batch adds skip it"; the Conventions list claimed "batch add/remove operations are non-reverting" without qualification; and `README.md` repeated that in the ERC-2980 section — while contradicting itself in the static-analysis triage table, which already recorded that batch adds revert on the sentinel on purpose. The NatSpec on all six batch-add functions documented only the duplicate-skipping half. All corrected; **no Solidity behaviour changed** — the code is deliberate and its inline reasoning was already right.
+ - New `README.md` section *Zero address in batch operations*: a single-vs-batch behaviour table, why the sentinel is rejected rather than skipped (the batch event echoes the input array, so skipping would name a non-member as a member), and the operational consequence — a batch containing one zero entry is rejected whole rather than partially applied.
+- **Contract code no longer references the technical documentation.** Four NatSpec comments in `src/` cited a `doc/technical/` page; the substance of each was written into the comment and the pointer removed (`RuleMaxBalance`, `RuleMaxBalanceOwnable2Step`, `RuleMaxBalanceBase`, `TokenSupplyReader`). The rule is now a project convention in `CLAUDE.md` / `AGENTS.md`: documentation paths move — the split above rewrote those same four comments the day after they were written — and a stale pointer inside a deployed contract's verified source cannot be fixed by editing the docs. `src/mocks/` is exempt, and audit reports remain citable by bare filename, being immutable records whose finding IDs carry context a comment cannot restate.
+
+- **`doc/technical/` is split in two.** Per-contract pages moved to [`doc/technical/contracts/`](./doc/technical/contracts/) (16 files) and the cross-cutting material to [`doc/technical/guides/`](./doc/technical/guides/) (`RULE_SEMANTICS.md`, `INVARIANT_TESTS.md`, `DEPLOYMENT_SCRIPTS.md`). The directory had grown to 19 files with no signal about which were per-contract reference and which spanned the whole library. **Every link into it changes**: 67 links inside the moved pages and 86 inbound references across the READMEs, CHANGELOG, agent guides, audit reports and source comments were rewritten, and all resolve.
+ - `RuleConditionalTransfer.md` was **orphaned** — nothing linked to it, including the doc index, so it was reachable only by browsing. It documents a rule maintained in a separate repository and is now listed in the index with that caveat rather than left unreferenced.
+
+- **The README is split in two.** `README.md` at the repository root is now a short summary — purpose, compatibility, architecture, the rule table, quick start, ERC-3643 integration, security tooling — and the full reference moved to [`doc/README.md`](./doc/README.md), unchanged in content. **Any deep link into the old root README's anchors now resolves against `doc/README.md` instead.** Rationale: the root file had grown to ~1 950 lines, which is a reference manual rather than a front page. `doc/script/convert_links_for_pdf.sh` was repointed at `doc/README.md` accordingly.
+- **The architecture diagrams are PlantUML, with sources committed.** The root README's ASCII topology block and `doc/README.md`'s two drawio images (`Rule-RuleEngine.drawio.png`, `Rule-Rule.drawio.png`) are replaced by rendered diagrams whose `.puml` sources live in [`doc/schema/`](./doc/schema/), so they can be regenerated and diffed. Each now carries a written description of the call sequence.
+ - **One of them was wrong.** The old direct-binding image showed an ERC-3643 token calling a bare rule, which the surrounding text already contradicted: a validation rule implements no `created` / `destroyed`, so it cannot back an ERC-3643 token. The replacement marks that path as unsupported and points to the RuleEngine topology.
+ - Both topology diagrams now show **both `transferred` overloads** — the 3-argument form for a plain `transfer()` and the 4-argument spender form for `transferFrom`, mint and burn — rather than only the 4-argument one.
+ - New ERC-3643 section in **both** READMEs covering the two pluggable slots. The identity material is organised by **what the token has** rather than by preference: an ERC-3643 token takes `IdentityRegistryWhitelist` directly in its identity slot, while CMTAT has no such slot, which is why `RuleIdentityRegistry` behind a RuleEngine is the only route there rather than one option among several. The corollary is stated explicitly — on ERC-3643, adding the rule on top of a registry the token already consults screens the same wallets twice and adds no restriction.
+ - `doc/technical/contracts/RuleIdentityRegistry.md` and `doc/technical/contracts/IdentityRegistryWhitelist.md` each gain a *when this applies* callout for the same reason: both described what the contract **is**, neither said which token standard it suits.
+- `CLAUDE.md` / `AGENTS.md`: the toolchain section now records **where** the ONCHAINID remapping is declared and why it must not go back into `remappings.txt`.
+- `doc/technical/contracts/RuleWhitelistWrapper.md`: the scan snippet in *Gas cost of the child-rule scan* now matches the implementation, with a note that the published cost table is a marginally conservative upper bound after the change.
+
+### Testing
+
+- New `test/ERC3643Real/ERC3643RealTokenRuleEngine.t.sol` — the same **ERC-3643 token → RuleEngine → RuleWhitelist** wiring, but against the **real vendored `Token.sol`** rather than a mock, so nothing in it is transcribed. 12 tests covering mint, transfer, transferFrom, forcedTransfer and burn, asserting the token's own `ComplianceNotFollowed` / `TransferNotPossible` errors and the rule's `RuleWhitelist_InvalidTransfer` codes.
+ - Requires its own Foundry profile: `Token.sol` pins `pragma solidity 0.8.30` exactly, which cannot share a compilation unit with the project's 0.8.36. `test/ERC3643Real/**` is skipped by the default profile and built by `[profile.erc3643]`. **CI must run both `forge test` and `FOUNDRY_PROFILE=erc3643 forge test`.**
+ - The `lib/ERC-3643` submodule moves from 4.1.3 to **4.2.0-beta1**; 4.1.3 pins `0.8.17`, which cannot compile alongside our `^0.8.20` contracts at all. Nothing in `src/` imports ERC-3643, so the bump affects tests only.
+ - Adds minimal `IIdentity` / `IClaimIssuer` stubs under `test/utils/onchainid/`, wired by a context-scoped remapping, because ONCHAINID is an npm dependency rather than a submodule.
+- New `test/ERC3643Compliance/ERC3643RuleEngineWhitelist.t.sol` — an ERC-3643 token wired to a `RuleEngine` as its **compliance** contract (`setCompliance`), enforcing `RuleWhitelist`: **ERC-3643 token → RuleEngine → RuleWhitelist**. Covers `mint`, `transfer`, `transferFrom`, `forcedTransfer` and `burn`, and pins that the compliance slot and the identity-registry slot block independently — an address verified by the registry but absent from the rule is rejected, and vice versa. Exercises the `setTokenSelfBindingApproval` path that exists in `ERC3643ComplianceExtendedModule` for ERC-3643 self-binding.
+- `ERC3643TokenMock` gained an optional compliance slot, with `canTransfer` / `transferred` / `created` / `destroyed` / `bindToken` call sites transcribed from `Token.sol`. Optional so the identity-registry suites keep running without an engine.
+
+- 79 new tests across unit, decimal-scaling, Ownable2Step access-control, and CMTAT + RuleEngine end-to-end suites, including a full-domain fuzz asserting the read path never reverts. 100% line coverage on both deployment variants.
+- Decimal-scaling suite covering token decimals 0 / 6 / 18 against feed decimals 0 / 8 / 18 / 36, the truncation behaviour at `tokenDecimals == 0`, and a fuzz cross-checking `_scaleReserve` against `answer * 10**tokenDecimals / 10**feedDecimals` computed with full-precision `mulDiv`.
+
+- New `test/RuleConditionalTransferLight/Ownable/ConditionalTransferOwnable2StepBindingAuthorization.t.sol` (7 tests) — owner-only authorization on both `Ownable2Step` conditional-transfer variants, which had **no access-control coverage at all**: the only test naming `RuleConditionalTransferLightMultiTokenOwnable2Step` was an ERC-165 support check. Three concrete overrides were unexercised (`_authorizeComplianceBindingChange` on the single-token variant, `_onlyComplianceManager` and `_authorizeTransferApproval` on the multi-token one); both contracts are now at 100% lines, statements and functions. Note which entrypoint reaches which hook: `RuleConditionalTransferLightBase` overrides `bindToken` with its own `onlyComplianceManager`, so on the single-token rule the only route to `_authorizeComplianceBindingChange` is the inherited `unbindToken`.
+- New `test/RuleMaxBalance/Ownable/RuleMaxBalanceOwnable2Step.t.sol` (10 tests) and two gap-filling unit tests, bringing `RuleMaxBalanceBase` to **100% statements and 100% branches** and both deployment variants to 100% across the board. The two gaps were a token with code whose `balanceOf` reverts (the `catch` in `_setBalanceToken`, unreachable from the mock's default state) and the second operand of `supportsInterface`, which a query for `IERC165` short-circuits past — it needs `IRULE_INTERFACE_ID`, which only `RuleTransferValidation` answers. The single remaining uncovered line is the abstract `_authorizeMaxBalanceManager` declaration, which no test can execute because only the override runs.
+- New `test/RuleConditionalTransferLight/Ownable/ConditionalTransferOwnable2StepBindingAuthorization.t.sol` (7 tests) — owner-only authorization on both `Ownable2Step` conditional-transfer variants, which had **no access-control coverage at all**. Note which entrypoint reaches which hook: `RuleConditionalTransferLightBase` overrides `bindToken` with its own `onlyComplianceManager`, so on the single-token rule the only route to `_authorizeComplianceBindingChange` is the inherited `unbindToken`.
+- `test/Version.t.sol` extended to the two new deployable contracts, keeping it exhaustive per the project convention.
+- New `test/Events/BatchEventEffect.t.sol` (9 tests) for the batch-event counters, pinning the case the input array could never express — a batch that is partly or wholly a no-op — for both the shared `RuleAddressSet` machinery and `RuleERC2980`'s separate copy of the same loops, plus a fuzz case asserting `added + skipped == input.length`.
+- **Three assertions in `test/RuleWhitelist/RuleWhitelistRemove.t.sol` were not assertions.** They contained bare `emit IAddressList.AddAddresses(...)` statements with no preceding `vm.expectEmit`, so they emitted an event from the test contract and checked nothing; the arity change surfaced them. They now use `vm.expectEmit`, and the most useful of them checks a 3-address removal where only 2 were present — `(removed = 2, skipped = 1)`.
+- New `script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol` — a CMTAT behind a RuleEngine enforcing three rules: `RuleBlacklist`, `RuleSanctionsList` and `RuleMaxTotalSupply`. Documents the two constraints this combination introduces that the two-rule script does not: `RuleMaxTotalSupply` validates its token at construction, so it must be deployed **after** the token; and one instance protects one token, so it must not be shared across RuleEngines.
+ - **Takes the acting `deployer` as an explicit parameter instead of reading `address(this)`.** The two execution contexts disagree about who makes the wiring calls — the broadcaster under `forge script`, the script contract under test — and Foundry rejects `address(this)` inside a broadcast outright ("script contracts are ephemeral"). The three pre-existing deployment scripts all read `address(this)` and therefore **revert under `forge script`**, passing their tests only because those call `deploy()` directly; they are unchanged here and still need that fix.
+- New `test/DeploymentScripts/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.t.sol` (19 tests) covering wiring, the admin hand-over (including that the deployer retains nothing), each rule enforcing alone, and the behaviour only the three together produce: which restriction code is reported when several rules object, an address rule blocking a mint the cap would have allowed, the cap leaving ordinary transfers untouched, and burning freeing headroom for a new mint.
+- New `test/ERC3643Real/RuleIdentityRegistryWithRealERC3643Registry.t.sol` (13 tests) — `RuleIdentityRegistry` consulting the **genuine ERC-3643 `IdentityRegistry`** vendored in `lib/ERC-3643`, with its real `IdentityRegistryStorage`, `ClaimTopicsRegistry` and `TrustedIssuersRegistry` behind it. Nothing in the repo built the reference registry before: the existing `ERC3643Real` suites plug `IdentityRegistryWhitelist` into `Token.sol`'s identity slot precisely to avoid ONCHAINID. Covers both regimes `isVerified` takes a different path through — no required claim topics (registered ⇒ verified, ONCHAINID never touched) and one required topic (topic iteration, trusted-issuer resolution, claim read, issuer validation) — plus claim revocation, an untrusted issuer, `deleteIdentity`, the receiver-only default, both opt-in flags, and mint/burn sentinel handling.
+ - Required extending the ONCHAINID stubs under `test/utils/onchainid/`: the reference `IdentityRegistry` does not compile against them, because `isVerified` calls `getClaim` on the investor's identity and `isClaimValid` on the issuer, and the stubs declared only `keyHasPurpose`. Both members are now declared, keeping the "only the slice genuinely used" doctrine those files were written under.
+ - New `test/ERC3643Real/utils/OnchainIdClaimMocks.sol`. **Limitation, stated in the file:** the mocks implement `getClaim` and `isClaimValid` only — the *registry's* logic runs for real, ONCHAINID's does not. No signature verification, key management or revocation.
+- New `test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol` (13 tests) — the two halves of the identity story working together: `RuleIdentityRegistry` *consults* a registry, `IdentityRegistryWhitelist` *is* one, so the chain `CMTAT -> RuleEngine -> RuleIdentityRegistry -> IdentityRegistryWhitelist` is now covered end to end. Until now each half was tested only against the other side's stand-in — the rule against `IdentityRegistryMock`, the registry inside an **ERC-3643** token's identity slot. **CMTAT has no `setIdentityRegistry` slot**, so this chain is the only way a CMTAT token can use `IdentityRegistryWhitelist` at all. Covers mint / transfer / transferFrom / burn, the unregistered-recipient rejections, that an unregistered *minter* may still mint (ERC-3643), that a de-listed holder can still exit but not receive (invariant I-1), that `isVerified(address(0))` stays false without breaking mint or burn (I-12), and both `checkSender` / `checkSpender` opt-ins against a real registry rather than a mock.
+- New `test/RuleConditionalTransferLight/TransferHashPreimage.t.sol` (4 tests) pinning the documented approval-key preimage for both conditional-transfer rules. They assert the documented formulations against the contract's own public `approvalCounts(bytes32)` getter — the real storage key — rather than against a reimplementation of the assembly, so the NatSpec and the code cannot drift apart. Two of them assert the negative case: the two standard encodings must **not** produce the key.
+- New `test/Events/ConstructorEvents.t.sol` (7 tests) for the three changes above, matching on `topic0` rather than `vm.expectEmit` so a wrong *number* of emissions is caught as well as a missing one. Four of the seven fail against the previous implementation — verified by reverting each change and re-running. It also pins `RuleChainlinkPoR`, which was already correct and is the rule the others were made to match, so the convention cannot regress from the other direction.
+- New `test_MA1_EngineAndTokenInheritTheHardcodedAllowedView_CurrentBehaviour` in `test/ThreatModel/ThreatModelTests.t.sol`, wiring `RuleMintAllowance` into a real `RuleEngine` inside a real CMTAT and asserting the hardcoded "allowed" at both the engine and token levels, the real answer from the 4-argument chain at both levels, and that the mint then reverts. Per the `_CurrentBehaviour` convention it asserts behaviour the audit considers wrong: closing the gap must make it fail. Verified as a genuine guard by temporarily returning a restriction code from the 3-argument view, which fails it with `70 != 0`.
+- New `testBatchAddRejectsZeroAddressAndAppliesNothing` and `testBatchAddStillSkipsDuplicates` in `test/RuleERC2980/RuleERC2980.t.sol`. The batch zero-address revert was covered only for `RuleAddressSetInternal`; `RuleERC2980` keeps its own copy of that guard, so its two batch adders had no coverage. The first test also pins that the batch is atomic — valid entries either side of the sentinel are not applied.
+- New `test/RuleSanctionsList/RuleSanctionsListDelegation.t.sol` (6 tests) and `src/mocks/harness/SanctionsListDelegationHarness.sol` for the `transferFrom` delegation fix. The harness is a subclass adding an oracle-independent check — the shape that exposes the defect. Two tests fail against the previous structure; the other four pin that the spender check still short-circuits ahead of the delegation and that base screening is untouched.
+- New `test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol` (8 tests) for the change above. The oracle in it deliberately sanctions `address(0)`; four of the tests fail against the previous implementation (mint blocked with code `30`, burn with `31`, and the write path reverting), while the four asserting unchanged behaviour pass either way — verified by reverting the guards and re-running.
+- New `test/VirtualHooks/VirtualHookOverride.t.sol` and `src/mocks/harness/VirtualHookOverrideHarnesses.sol`, guarding the `virtual` convention above. The harnesses override the previously-unoverridable hooks, so removing `virtual` fails the build with *"Trying to override non-virtual function"*; the tests then assert the overrides are actually reached — a custom executor policy authorizes a caller the base policy rejects, `super` still returns the base blacklist code, and both overridden `canTransfer` overloads answer `false` while the underlying restriction hook still says `TRANSFER_OK`. For E-3 the coverage is **representative, not exhaustive** — one override per family (address-set write, ERC-2980 list write, configuration setter, approval write, `transferred` hook), each asserted to run and to still reach `super`. `virtual` is applied per function, so 21 of the 27 have no compile-time guard; exhaustive harnesses were judged bulk without signal.
+- New `testDetectTransferRestrictionOkWhenAddressListedInSeveralChildRules` in `test/RuleWhitelist/WhitelistWrapper.t.sol`. Branch coverage of `RuleWhitelistWrapperBase.sol` stays at 100% (19/19) with the added condition.
+- New `testApproveTransfer_EmitsPostIncrementCount` (`test/RuleConditionalTransferLight/RuleConditionalTransferLightUnit.t.sol`) and `test_ApproveTransferEmitsPostIncrementCount` (`test/RuleConditionalTransferLightMultiToken/MultiTokenSurface.t.sol`). The approval-counter change is pure codegen with identical event data, but **no test asserted the `TransferApproved` payload at all** — the suites only checked `approvedCount()`. Both new tests approve the same transfer twice and require the event to report 1 then 2, so a pre-increment or uninitialised value would now fail.
+
## v0.4.0 - 2026-07-14
+Commit: `44cec0ebc7d9eba7644f9f4d1c52e832e2791369`
+
### Summary
Two new rule families, two standards-conformance fixes, and hardening from an internal review.
@@ -115,17 +374,17 @@ Two new rule families, two standards-conformance fixes, and hardening from an in
### Documentation
-- `RuleConditionalTransferLightMultiToken`: document that the rule is **direct-binding-only** and **must not be added to a `RuleEngine`**. Approvals are recorded under the `token` argument but consumed under `msg.sender`, so behind an engine every wiring either reverts or silently loses per-token isolation. Added a "Deployment topology" section with the exhaustive case analysis to `doc/technical/RuleConditionalTransferLightMultiToken.md`, documented the caller-dependent `detectTransferRestriction`, and propagated the constraint to the README binding-model table, `RULE_SEMANTICS.md` and the project guide.
+- `RuleConditionalTransferLightMultiToken`: document that the rule is **direct-binding-only** and **must not be added to a `RuleEngine`**. Approvals are recorded under the `token` argument but consumed under `msg.sender`, so behind an engine every wiring either reverts or silently loses per-token isolation. Added a "Deployment topology" section with the exhaustive case analysis to `doc/technical/contracts/RuleConditionalTransferLightMultiToken.md`, documented the caller-dependent `detectTransferRestriction`, and propagated the constraint to the README binding-model table, `RULE_SEMANTICS.md` and the project guide.
- `RuleWhitelistWrapper`: document the child-rule scan cost model and publish operator guidance. The wrapper makes one external `STATICCALL` per child (**~8.8k gas each**) and the scan runs during transfer *execution*, so it is paid by the transferring user on every transfer — not only in views. At the default `maxRules = 10` the worst case is ~90k gas per transfer (~121k with `checkSpender`). Two amplifiers are documented: a transfer that will be *rejected* never early-exits and therefore always scans all children, and `checkSpender = true` adds a third target address that must also be resolved. The scan is linear (marginal cost measured flat at ~8.8k gas/child from 25 to 200 children). Guidance: keep the child list at or below the default cap, order children by expected hit rate, and treat raising `maxRules` as a permanent per-transfer cost on every holder (a cap of 100 ⇒ ~884k gas/transfer, measured). This is a cost problem rather than a liveness one — transfers still fit in a block until roughly 3,400 children. The list size remains the **operator's responsibility**; no lower cap is hard-coded.
-- Add `doc/technical/INVARIANT_TESTS.md` — documents the stateful invariant suite: handler architecture and ghost variables, each of the four invariants and what it proves, the mutation-testing negative controls, the coverage map against the threat-model invariants, and how to add a new invariant. Linked from a new "Invariant testing" section in the README.
-- Add `doc/technical/RULE_SEMANTICS.md` — a per-rule comparison table (who each rule screens for `from` / `to` / spender on `transferFrom` / mint / burn, behaviour when the oracle/registry is unset, stateful?, and which pre-flight view is authoritative), with a highlights summary and link added to the README.
-- `RuleMintAllowance`: document that `canTransfer` / `detectTransferRestriction` are **not authoritative** (hardcoded to "allowed" because the 3-arg path has no minter identity) and that a mint pre-flight must use the spender-aware `canTransferFrom(minter, address(0), to, value)` / `detectTransferRestrictionFrom`. Added a bold callout and an eligibility-views table to `doc/technical/RuleMintAllowance.md` and a warning to the README rule section.
-- Add [`CLAUDE_AUDIT.md`](./doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — the published AI-assisted security audit report for `v0.4.0` (0 Critical/High/Medium, 2 Low, 8 Info), with invariant verification, access-control verification, the remediation record and the open improvement backlog. Backed by the working deliverables `THREAT_MODEL.md`, `RESULT.md` and `TEST_IMPROVEMENT.md`, plus Slither call-graph / inheritance / function-summary comprehension artifacts.
+- Add `doc/technical/guides/INVARIANT_TESTS.md` — documents the stateful invariant suite: handler architecture and ghost variables, each of the four invariants and what it proves, the mutation-testing negative controls, the coverage map against the threat-model invariants, and how to add a new invariant. Linked from a new "Invariant testing" section in the README.
+- Add `doc/technical/guides/RULE_SEMANTICS.md` — a per-rule comparison table (who each rule screens for `from` / `to` / spender on `transferFrom` / mint / burn, behaviour when the oracle/registry is unset, stateful?, and which pre-flight view is authoritative), with a highlights summary and link added to the README.
+- `RuleMintAllowance`: document that `canTransfer` / `detectTransferRestriction` are **not authoritative** (hardcoded to "allowed" because the 3-arg path has no minter identity) and that a mint pre-flight must use the spender-aware `canTransferFrom(minter, address(0), to, value)` / `detectTransferRestrictionFrom`. Added a bold callout and an eligibility-views table to `doc/technical/contracts/RuleMintAllowance.md` and a warning to the README rule section.
+- Add [`CLAUDE_AUDIT.md`](./doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — the published AI-assisted security audit report for `v0.4.0` (0 Critical/High/Medium, 2 Low, 8 Info), with invariant verification, access-control verification, the remediation record and the open improvement backlog. Backed by internal working deliverables (threat model, findings, test-gap analysis) plus Slither call-graph / inheritance / function-summary comprehension artifacts.
- Add a "Manual Threat Model & Review" section to `README.md`.
- `CLAUDE.md` / `AGENTS.md`: correct the version string to `0.4.0`, document the two integration topologies and the CMTAT v3.3+ mint `spender` convention, and add the missing `RuleMintAllowance`, `RuleConditionalTransferLightMultiToken`, `RuleNFTAdapter` and restriction code `70` entries.
-- Added technical documentation: `doc/technical/RuleConditionalTransferLightMultiToken.md`.
+- Added technical documentation: `doc/technical/contracts/RuleConditionalTransferLightMultiToken.md`.
- Updated README operation-rule sections and tables to include `RuleConditionalTransferLightMultiToken`.
-- Added technical documentation: `doc/technical/RuleMintAllowance.md`.
+- Added technical documentation: `doc/technical/contracts/RuleMintAllowance.md`.
- Updated restriction code table, rule index, role summary, and Ownable2Step list in README.
- Documented that `RuleMintAllowance` does not work with pure ERC-3643 3-arg mint callbacks; it requires the spender-aware CMTAT/RuleEngine path.
@@ -206,7 +465,7 @@ Commit: [`d72a98a`](https://github.com/CMTA/Rules/commit/d72a98abbba29cd82a7056b
- `RuleSpenderWhitelist` — validation rule that blocks `transferFrom` when spender is not listed; direct transfers are always allowed. Restriction code 66.
- `RuleSpenderWhitelistOwnable2Step` — Ownable2Step variant of `RuleSpenderWhitelist`.
-- Technical documentation file `doc/technical/RuleSpenderWhitelist.md`.
+- Technical documentation file `doc/technical/contracts/RuleSpenderWhitelist.md`.
- Transfer-context mocks in `src/mocks`: `MockERC20WithTransferContext` and `MockERC721WithTransferContext`.
- Transfer-context mocks in `src/mocks` now inherit OpenZeppelin `ERC20` / `ERC721` and emit rule callbacks through `ITransferContext`.
- Transfer-context tests for ERC-20/ERC-721 mock integration in `test/TransferContext/TransferContextMocks.t.sol`.
diff --git a/CLAUDE.md b/CLAUDE.md
index bec6fee3..eb9d690c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -26,26 +26,30 @@ Operation rules that treat `msg.sender` or `getTokenBound()` as a *token identit
`CMTAT._mintOverride` calls `_checkTransferred(_msgSender(), address(0), to, value)`, so **on every mint the minter's address arrives at each rule as `spender`** via the 4-arg `transferred` overload. Plain `transfer()` passes `spender == address(0)` and takes the 3-arg path.
- `RuleWhitelist`, `RuleSpenderWhitelist`, `RuleWhitelistWrapper` explicitly exempt mint/burn from the spender check.
-- `RuleIdentityRegistry`, `RuleBlacklist`, `RuleSanctionsList`, `RuleERC2980` do **not** — they screen the minter. For the deny-lists this is intended; for `RuleIdentityRegistry` it means the minter must itself be identity-verified (see `RESULT.md` F-1).
+- `RuleIdentityRegistry`, `RuleBlacklist`, `RuleSanctionsList`, `RuleERC2980` do **not** — they screen the minter. For the deny-lists this is intended; for `RuleIdentityRegistry` it means the minter must itself be identity-verified (see `CLAUDE_AUDIT.md` F-1).
- `RuleMintAllowance` is the only rule that *uses* the mint spender: it debits `mintAllowance[spender]`.
+- `RuleMaxTotalSupply` and `RuleChainlinkPoR` ignore the spender entirely — they cap *supply*, not identities, and act only when `from == address(0)`.
-Full per-rule semantics (who each rule screens, mint/burn handling, unset-oracle behaviour, stateful?, authoritative view) are tabulated in `doc/technical/RULE_SEMANTICS.md` — consult it before assuming any rule behaves like its siblings.
+Full per-rule semantics (who each rule screens, mint/burn handling, unset-oracle behaviour, stateful?, authoritative view) are tabulated in `doc/technical/guides/RULE_SEMANTICS.md` — consult it before assuming any rule behaves like its siblings.
### Standards conformance (non-negotiable)
Rules that implement a standardized interface must match that standard's semantics, not merely its function signatures. Specs are vendored in `doc/ERCSpecification/` — read them before changing a rule's screening logic.
- **`RuleIdentityRegistry` conforms to ERC-3643 (enforced, I-1).** The spec mandates that **only the receiver** be identity-verified: *"The receiver MUST be whitelisted on the Identity Registry and verified"*; `transferFrom` "works the same way"; `mint` and `forcedTransfer` "only require the receiver"; `burn` "bypasses all checks on eligibility". The sender, the spender and the minter are **not** required to be verified — do not re-add those checks as defaults. Screening the sender **traps de-listed holders** (the spec checks only the receiver precisely so a lapsed investor can still exit their position). Stricter screening is available as an explicit opt-in via the `checkSender` / `checkSpender` flags, both defaulting to `false`.
-- **`isVerified(address(0))` must be `false`** — ERC-3643 defines `isVerified` as "is this wallet a valid investor holding the required claims", and `address(0)` is not a wallet. Likewise `RuleERC2980`'s `whitelist(address)` / `frozenlist(address)` are MANDATORY ERC-2980 getters and must not return `true` for `address(0)`. **Enforced (I-12):** mint/burn permission is an explicit `allowMint` / `allowBurn` flag, and the zero address can never enter any list — single adds revert, batch adds skip it. Never re-introduce "whitelist `address(0)` to enable mint/burn".
+- **`isVerified(address(0))` must be `false`** — ERC-3643 defines `isVerified` as "is this wallet a valid investor holding the required claims", and `address(0)` is not a wallet. Likewise `RuleERC2980`'s `whitelist(address)` / `frozenlist(address)` are MANDATORY ERC-2980 getters and must not return `true` for `address(0)`. **Enforced (I-12):** mint/burn permission is an explicit `allowMint` / `allowBurn` flag, and the zero address can never enter any list — **both single and batch adds revert on it**. The batch functions skip *duplicates* but reject `address(0)`, deliberately: silently dropping it would make the emitted `AddAddresses` event name the sentinel as a set member, re-polluting the off-chain view the guard exists to keep clean. Never re-introduce "whitelist `address(0)` to enable mint/burn".
## Key Directories
| Path | Description |
|---|---|
| `src/rules/validation/` | Read-only rules (view functions, no state changes during transfer) |
| `src/rules/operation/` | Read-write rules (modify state on transfer) |
-| `src/rules/validation/abstract/core/` | `RuleTransferValidation` (ERC-1404/3643/7551 views), `RuleNFTAdapter` (ERC-7943 + `ITransferContext` overloads), `RuleWhitelistShared` |
+| `src/rules/validation/abstract/core/` | `RuleTransferValidation` (ERC-1404/3643/7551 views), `RuleNFTAdapter` (ERC-7943 + `ITransferContext` overloads), `RuleWhitelistShared`, `TokenSupplyReader` (revert-free `totalSupply()` read, shared by `RuleMaxTotalSupply` and `RuleChainlinkPoR`), `ChainlinkPoRFeedManager` (PoR feed configuration + the revert-free reserve read; **no constructor, no ERC-1404**, so it is reusable and initializer-agnostic), `TotalSupplyCapManager` and `BalanceCapManager` (the same split for `RuleMaxTotalSupply` and `RuleMaxBalance`: state, setters and the revert-free read, with the constructor and the restriction-code mapping left in the rule) |
| `src/rules/validation/abstract/` | Shared base contracts and invariant storage |
-| `src/rules/interfaces/` | Shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITotalSupply`, `ITransferContext`, `IERC2980`, `IERC7943NonFungibleCompliance`) |
+| `src/rules/interfaces/` | Shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITotalSupply`, `ITransferContext`, `IERC2980`, `IERC7943NonFungibleCompliance`, `AggregatorV3Interface`, `IDecimals`) |
+| `src/registry/` | Contracts filling a token's **identity registry** slot, not its compliance slot (`IdentityRegistryWhitelist`). Not rules: no `IRule`, never added to a RuleEngine |
| `src/modules/` | Reusable modules (`AccessControlModuleStandalone`, `MetaTxModuleStandalone`, `VersionModule`, `Ownable2StepERC165Module`) |
+| `doc/technical/contracts/` | One documentation page per deployable contract |
+| `doc/technical/guides/` | Cross-cutting docs: `RULE_SEMANTICS.md`, `INVARIANT_TESTS.md`, `DEPLOYMENT_SCRIPTS.md` |
| `test/` | Foundry tests, one folder per rule |
| `lib/` | Git submodule dependencies (do not edit) |
@@ -61,17 +65,21 @@ Rules that implement a standardized interface must match that standard's semanti
| Contract | Role |
|---|---|
| `RuleWhitelist` / `RuleWhitelistOwnable2Step` | Allow transfers only between whitelisted addresses |
+| `RuleReceiverWhitelist` / `RuleReceiverWhitelistOwnable2Step` | Screen **only the receiver**, reproducing ERC-3643 eligibility. Sender and spender are never checked — do not add those, it traps de-listed holders (same reasoning as I-1). Burn is exempt (`to == address(0)` can never be listed); mint is screened on the receiver with no `allowMint` flag. Code 81 |
| `RuleWhitelistWrapper` / `Ownable2Step` | Aggregate multiple whitelist rules (OR logic) |
| `RuleBlacklist` / `RuleBlacklistOwnable2Step` | Block transfers involving blacklisted addresses |
| `RuleSanctionsList` | Block sanctioned addresses via Chainalysis oracle |
+| `RuleMaxBalance` / `RuleMaxBalanceOwnable2Step` | Cap how many tokens **one address** may hold, with an operator-managed exemption list. Screens the receiver only; burns exempt; mints capped. Codes 82, 83. **Bypassable by splitting across wallets** — pair with a rule admitting one address per investor (`RuleWhitelist` / `RuleReceiverWhitelist` / `RuleIdentityRegistry`) and an onboarding policy of one address per entity. `maxBalance = 0` forbids holding, it does NOT disable the rule |
| `RuleMaxTotalSupply` | Cap minting so total supply never exceeds a maximum |
+| `RuleChainlinkPoR` / `RuleChainlinkPoROwnable2Step` | Cap minting at the reserves reported by a Chainlink Proof of Reserve feed (`AggregatorV3Interface`). The limit equals the reported reserves exactly — no margin parameter (deliberately dropped from Chainlink's `SecureMintPolicy`); compose with `RuleMaxTotalSupply` for a static cap. Mints only; transfers and burns always pass, so a stale or broken feed never traps holders. The read path is guarded (`code.length` check + `try/catch` + saturating arithmetic) so the ERC-1404 views never revert |
| `RuleIdentityRegistry` | Check ERC-3643 identity registry for participant verification |
+| `IdentityRegistryWhitelist` | The mirror image of `RuleIdentityRegistry`: **is** an ERC-3643 identity registry, backed by a whitelist, so no ONCHAINID is needed. Keeps **no identity state** — `_identity` and `_country` are accepted for signature compatibility then discarded, and `investorCountry` is a constant 0; do not add identity storage back. Inherits `RuleAddressSetInternal` (the same set machinery as `RuleWhitelist`) rather than deploying or re-implementing a whitelist — **only the internal layer**, because `RuleAddressSet`'s public `addAddress`/`removeAddress` are not `virtual` and so could not maintain the `keyHasPurpose` reverse index; a second write path would produce verified-but-unrecoverable wallets. Installed with `token.setIdentityRegistry()`. Implements **no** ERC-734 surface: `recoveryAddress` needs a real ONCHAINID as `_investorOnchainID`. A `keyHasPurpose` implementation was tried and removed — the agent chooses which contract that call lands on, so it added no security while forcing a hash-to-wallet reverse index and duplicate-tolerant registration. Do not re-add it. The token itself must hold `IDENTITY_REGISTRAR_ROLE`. See `doc/technical/contracts/IdentityRegistryWhitelist.md` |
| `RuleSpenderWhitelist` / `RuleSpenderWhitelistOwnable2Step` | Allow `transferFrom` only when spender is whitelisted; direct transfers are always allowed |
| `RuleERC2980` | ERC-2980 Swiss Compliant rule: whitelist (recipient-only) + frozenlist (blocks sender, recipient, and spender for `transferFrom`); frozenlist takes priority |
| `RuleERC2980Ownable2Step` | Ownable2Step variant of RuleERC2980 |
| `RuleConditionalTransferLight` | Require operator approval before each transfer; bound to exactly one token at a time (`bindToken` reverts if a token is already bound; use `unbindToken` first to migrate) |
| `RuleConditionalTransferLightOwnable2Step` | Owner-only approval and execution for conditional transfers |
-| `RuleConditionalTransferLightMultiToken` / `…Ownable2Step` | Conditional transfers with approvals keyed `(token, from, to, value)`. **Direct-binding-only (Topology B)** — approvals are *consumed* under `msg.sender`, so this rule must NOT be added to a RuleEngine; behind an engine it either reverts or loses all per-token isolation. See `RESULT.md` F-4 and `doc/technical/RuleConditionalTransferLightMultiToken.md` |
+| `RuleConditionalTransferLightMultiToken` / `…Ownable2Step` | Conditional transfers with approvals keyed `(token, from, to, value)`. **Direct-binding-only (Topology B)** — approvals are *consumed* under `msg.sender`, so this rule must NOT be added to a RuleEngine; behind an engine it either reverts or loses all per-token isolation. See `CLAUDE_AUDIT.md` F-4 and `doc/technical/contracts/RuleConditionalTransferLightMultiToken.md` |
| `RuleMintAllowance` / `RuleMintAllowanceOwnable2Step` | Per-minter mint quota, debited on the 4-arg `transferred(spender, from=0, to, value)` path. Requires CMTAT ≥ v3.3. `canTransfer` is **not** authoritative for this rule — use `canTransferFrom(minter, address(0), to, value)` |
| `AccessControlModuleStandalone` | Base RBAC module; admin implicitly holds all roles |
| `MetaTxModuleStandalone` | ERC-2771 meta-transaction support. Note: the operation rules deliberately do **not** inherit this, so `_msgSender()` used as a binding identity is never forwarder-controlled |
@@ -79,21 +87,44 @@ Rules that implement a standardized interface must match that standard's semanti
| `VersionModule` | Implements `IERC3643Version`; returns the contract version string |
## Dependencies (lib/)
-- `openzeppelin-contracts` v5.6.1 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context`
-- `openzeppelin-contracts-upgradeable` v5.6.1
-- `CMTAT` v3.0.0 — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces
-- `RuleEngine` v3.0.0-rc4 — `IRule`, `RulesManagementModule`
+- `openzeppelin-contracts` v5.7.0 — `AccessControl`, `Ownable2Step`, `EnumerableSet`, `ERC2771Context`
+- `openzeppelin-contracts-upgradeable` v5.7.0
+- `CMTAT` v3.3.0-rc3 (submodule pin; library supports ≥ v3.0.0) — `IERC1404`, `IERC3643`, `IRuleEngine` interfaces
+- `RuleEngine` v3.0.0-rc5 — `IRule`, `RulesManagementModule`
- `forge-std` — Foundry test utilities
-Remappings are in `remappings.txt`; aliases used in source: `OZ/`, `CMTAT/`, `RuleEngine/`.
+Remappings are in `remappings.txt`; aliases used in source: `@openzeppelin/`, `CMTAT/`, `RuleEngine/`.
## Toolchain
```bash
forge build # compile
forge test # run all tests
forge test -vvv # verbose output
+
+FOUNDRY_PROFILE=erc3643 forge test # the real-ERC-3643-token suite (see below)
```
-Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
+Foundry config: `foundry.toml` (solc 0.8.36, EVM prague, optimizer 200 runs).
+
+**There are two profiles, and `forge test` alone does not run everything.** The vendored ERC-3643
+`Token.sol` pins `pragma solidity 0.8.30` *exactly*, which cannot share a compilation unit with our
+0.8.36. So `test/ERC3643Real/**` is in the default profile's `skip` list and is built by
+`[profile.erc3643]` at solc 0.8.30 instead (our contracts are `^0.8.20`, so they compile there too).
+CI must run **both** commands. Gotchas: profiles inherit unspecified keys from `[profile.default]`,
+so that profile has to clear `skip = []` explicitly; and it writes to `out-erc3643/` to avoid
+clobbering the 0.8.36 artifacts.
+
+ERC-3643 imports `@onchain-id/solidity`, which is an npm package rather than a submodule and so is
+not vendored. `test/utils/onchainid/` holds minimal `IIdentity` / `IClaimIssuer` stubs wired in by a
+**context-scoped** remapping (`lib/ERC-3643/:@onchain-id/solidity/contracts/=test/utils/onchainid/`)
+so they apply to the ERC-3643 build only. Only `keyHasPurpose` is ever called; everywhere else those
+types appear as parameters or event fields, which canonicalise to `address` and affect no selector.
+
+That remapping is declared as `remappings = [...]` inside `[profile.erc3643]` in `foundry.toml`, **not
+in `remappings.txt`** — and it must stay there. `forge remappings` prints `remappings.txt` for every
+profile; `hardhat-foundry` runs exactly that command and rejects any line containing a `:` with
+*"remapping contexts are not allowed"*, which breaks `npx hardhat test` (a CI step). As profile
+config it is applied only when that profile is selected, so the default and `ci` profiles Hardhat
+sees stay context-free. Hardhat compiles only `src/` (Foundry's `src`), so it never needs the stubs.
## Restriction Code Ranges
| Rule | Codes |
@@ -102,11 +133,14 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
| RuleSanctionsList | 30–32 |
| RuleBlacklist | 36–38 |
| RuleConditionalTransferLight / …MultiToken | 46 |
-| RuleMaxTotalSupply | 50 |
+| RuleMaxTotalSupply | 50, 51 (total supply unavailable) |
| RuleIdentityRegistry | 55–57 |
| RuleERC2980 | 60–63, 64 (mint not allowed), 65 (burn not allowed) |
| RuleSpenderWhitelist | 66 |
| RuleMintAllowance | 70 |
+| RuleChainlinkPoR | 75 (reserves exceeded), 76 (feed stale), 77 (answer returned but unusable), 78 (total supply unavailable), 79 (feed unreadable) |
+| RuleReceiverWhitelist | 81 |
+| RuleMaxBalance | 82, 83 (balance unavailable) |
## Conventions
- Each rule has an `InvariantStorage` abstract contract holding its constants, custom errors, and events.
@@ -115,26 +149,39 @@ Foundry config: `foundry.toml` (solc 0.8.34, EVM prague, optimizer 200 runs).
- **All `_authorize*()` / `_only*()` access-control hooks are `internal view virtual`** — both the abstract declaration and every override. An authorization hook checks and reverts; it must never mutate state, and `view` makes that a compiler-enforced invariant rather than a convention. It is free: these are `internal`, so `view` costs no gas and changes no runtime behaviour. Both OZ check functions (`AccessControl._checkRole`, `Ownable._checkOwner`) are `view`, so every hook can be.
- **One documented exception**: `RuleConditionalTransferLightMultiTokenBase._authorizeComplianceBindingChange` cannot be `view`, because it delegates to `_onlyComplianceManager()`, which `lib/RuleEngine` declares non-`view` (Solidity checks mutability against a virtual's *declared* type, not the installed override). If you hit this constraint elsewhere, document why inline — do not silently drop `view` from a hook.
- AccessControl variants treat the default admin as having all roles via `hasRole`, but the admin may not appear in role member enumerations unless explicitly granted.
-- All rules implement `IERC3643Version` via `VersionModule`; the current version string is `"0.4.0"` (asserted by `test/Version.t.sol`).
+- All rules **and `IdentityRegistryWhitelist`** implement `IERC3643Version` via `VersionModule`; the current version string is `"0.5.0"`. `test/Version.t.sol` asserts it for every deployable contract — keep it exhaustive, a half-covered version test reads as authoritative while missing the mirror it exists to catch.
- **ERC-165 interface IDs**: `type(IFoo).interfaceId` only XORs selectors defined directly on `IFoo` and does NOT include selectors from inherited interfaces. Always use the pre-computed library constants instead: `ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID` (from `CMTAT/library/`), `RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID` (from `CMTAT/library/`), and `RuleInterfaceId.IRULE_INTERFACE_ID` (from `RuleEngine/modules/library/`). If no pre-computed constant exists for an interface, define a flat mock interface that redeclares all functions from the full inheritance tree and use `type(IFooFlattened).interfaceId` to compute the correct value (see `lib/RuleEngine/src/mocks/IRuleInterfaceIdHelper.sol` for the established pattern).
-- Batch add/remove operations are non-reverting (skip duplicates); single-item operations revert on invalid input.
+- Batch add/remove operations are non-reverting **for duplicates and missing entries** (those are skipped and counted); single-item operations revert on the same input. The one exception is `address(0)`, which **reverts on every add path, batch included** — see I-12. A batch containing a zero entry therefore fails as a whole rather than being partially applied.
- All `internal` functions should be marked `virtual`.
- Do not create git commits; provide commit messages only when requested.
- Always run full tests (`forge test`) after any code modification, including lint-driven or mechanical refactors, before reporting completion.
- Use `require(condition, CustomError(...))` for custom errors; avoid direct `revert CustomError(...)`.
+- **Keep NatSpec blocks short — 20 lines is the ceiling, and most should be far shorter.** Measured over `src/`, the median block is **4 lines** and the 90th percentile is **8**; anything past 20 is an outlier that has stopped being a comment and become a document. A reader opening a contract wants to know what it does and what will bite them, not read an essay before the first line of code. Long blocks also rot faster: the more claims a comment makes, the more of them silently go stale.
+ - **State the conclusion and the warning; leave the derivation to `doc/technical/`.** This is the same rule as the no-cross-reference convention above, applied from the other end — that one says do not replace the substance with a pointer, this one says do not inflate the substance into a treatise. Both point at the same target: the comment carries what a reader with only the verified source must know, and the doc carries the reasoning.
+ - **What earns its place in a long block**: a safety precondition (`must never revert`, and why that holds), a footgun (`maxBalance = 0` forbids holding, it does not disable the rule), and a non-obvious design constraint. **What does not**: restating what the code says, narrating the refactor that produced the file, or listing benefits.
+- **Never reference a `doc/technical/` page from contract code.** NatSpec and comments in `src/` must not cite `doc/technical/contracts/*.md` or `doc/technical/guides/*.md`, by path or by bare filename. Documentation paths move — the `contracts/` + `guides/` split rewrote four source comments that had been correct the day before — and a stale pointer inside a deployed contract's source cannot be fixed by editing the docs. **Write the substance into the comment instead**: a reader with only the verified source must get the whole warning, not a breadcrumb to a file they may not have. If the explanation is too long for NatSpec, it is a sign the comment should state the conclusion and the doc should carry the derivation, with no cross-reference in the code.
+ - **Exception: `src/mocks/`.** Test doubles are never deployed as production contracts and exist to serve the test suite, so a pointer to the page explaining what they stand in for is useful and carries no cost.
+ - **Audit reports are a separate case and stay allowed**, cited by bare filename (`CLAUDE_AUDIT.md`, `CLAUDE_ANALYSIS.md`, `CLAUDE_ANALYSIS_SCRIPT.md`, `CLAUDE_ANALYSIS_MAXBALANCE.md`). They are immutable historical records of a finding, the bare filename survives the file being moved, and the finding ID is what gives a reviewer the context a comment cannot restate.
- **No emoji in code comments or NatSpec.** Use a plain word marker instead: `WARNING:`, `NOTE:`, `IMPORTANT:`. Emoji render inconsistently across editors, terminals, `forge doc` output and diffs; they are not searchable (`grep WARNING` finds the marker, `grep ⚠️` depends on the shell); and they encode as multi-byte sequences that can be silently mangled by tooling. This applies to `src/`, `test/` and `script/`. Markdown documentation may use emoji freely — the restriction is Solidity comments only.
- `AGENTS.md` and `CLAUDE.md` are identical — always update both together.
-- Always update README.md with the latest change
-- New rule or features implemented: create/update technical documentation in `doc/technical`, update README, create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
+- **Two READMEs.** `README.md` at the root is a short summary (purpose, architecture, rule list, quick start) and is the GitHub front page; `doc/README.md` is the full reference. Update `doc/README.md` with the latest change, and the root `README.md` only when the summary itself becomes wrong (a new rule, a changed code range, a moved document). Links inside `doc/README.md` are relative to `doc/`.
+- New rule or features implemented: create/update technical documentation — a per-contract page in `doc/technical/contracts/`, or `doc/technical/guides/` when the material spans several contracts, update `doc/README.md` (and the root summary if the rule table or code ranges change), create/update test (target: 100% of code coverage), update CHANGELOG.md. Code coverage, run `forge coverage --report summary`
- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit.
## Security Findings Reference
-- [`THREAT_MODEL.md`](THREAT_MODEL.md) — trust model, 30 catalogued threats with IDs, data-flow diagrams, 12 invariants.
-- [`RESULT.md`](RESULT.md) — findings (0 High/Medium, 2 Low, 8 Info), invariant and access-control verification, disposition of every threat ID.
-- [`TEST_IMPROVEMENT.md`](TEST_IMPROVEMENT.md) — test-gap analysis and the deferred test backlog.
+- [`doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md`](doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — the v0.4.0 security audit: trust model, catalogued threats and invariants, findings (0 High/Medium, 2 Low, 8 Info), and the disposition of every threat ID. Source comments cite it by bare filename, `CLAUDE_AUDIT.md`.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md) — code-quality review (duplication, missing events, gas, `virtual` convention, behaviour at odds with the library's purpose). 28 findings with the disposition and commit for each, including two whose gas claims were wrong and one whose proposed remedy did not work. Source comments cite it by bare filename, `CLAUDE_ANALYSIS.md`, so the path can move.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md) — deployment-script review (`script/`). 12 findings, all implemented, including three scripts that reverted under `forge script` and a test-methodology gap that hid it. Source comments cite it by bare filename, `CLAUDE_ANALYSIS_SCRIPT.md`, so the path can move.
+- [`doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md`](doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md) — code-quality review of `RuleMaxBalance` and the `ChainlinkPoRFeedManager` split. 12 findings, 2 implemented, 4 deliberately left, including a measured decision to KEEP the exemption-before-balance check order and the pre-update accounting assumption the cap rests on. Source comments cite it by bare filename, `CLAUDE_ANALYSIS_MAXBALANCE.md`.
- [`test/ThreatModel/ThreatModelTests.t.sol`](test/ThreatModel/ThreatModelTests.t.sol) — 18 PoCs. Tests suffixed `_CurrentBehaviour` assert behaviour the audit considers wrong; **fixing the underlying issue must make them fail**, at which point update the test and the finding together.
Gotchas worth knowing before you change anything:
+- **Deployment scripts must take the deployer as a parameter, never read `address(this)`.** Under `forge script` the broadcaster makes the calls, not the script contract, and Foundry rejects `address(this)` inside a broadcast outright. A script that reads it passes every unit test and reverts on the real deployment path, because the tests call `deploy()` directly and never enter a broadcast context. **No unit test can cover this**: Foundry refuses to combine a prank with a broadcast, so `run()` cannot be faithfully exercised from `forge test`. The guard is the `forge script` dry-run step in CI, which runs every `script/*.s.sol`. Shared token metadata and env configuration live in `script/base/CMTATDeploymentBase.sol`. See `CLAUDE_ANALYSIS_SCRIPT.md`.
- `HelperContract` already inherits `RuleConditionalTransferLightInvariantStorage`; inheriting the multi-token variant alongside it is a compile error (`OPERATOR_ROLE`, `CODE_TRANSFER_REQUEST_NOT_APPROVED` clash).
- `RuleWhitelistWrapperBase._detectTransferRestrictionForTargets` short-circuits once every target address is resolved, so a broken child rule may never be reached for some address pairs.
- `RuleWhitelistWrapper` does not ERC-165-check its child rules (unlike `RuleEngineBase._checkRule`); a non-`IAddressList` child bricks the scan.
+- `RuleChainlinkPoR` reads the feed's `decimals()` **live on every check** and deliberately does NOT cache it. Caching saves ~2,900 gas per mint but lets an aggregator migration that changes decimals mis-scale the reserves by `10 ** delta` with no on-chain signal — in the overstating direction that is unlimited unbacked minting. Both feed calls share the `code.length` guard (Solidity's extcodesize revert on a `try` to a codeless address is uncatchable) and `MAX_FEED_DECIMALS` is re-checked at read time, not just at configuration. Do not "optimise" this back into a cache.
+- `RuleChainlinkPoR` (and `RuleMaxTotalSupply`) protect **one token per instance** with no on-chain guard: they read `totalSupply()` from the configured `tokenContract`, never from the token that triggered the check, and behind a RuleEngine they cannot learn that identity. One instance added to two RuleEngines evaluates both tokens against the first one's supply and feed — silently over-minting or freezing the second. Chainlink's `SecureMintPolicy` blocks this with `onInstall`/`PolicyAlreadyBound`; adding an equivalent here would mean making a stateless validation rule bindable, which is a library-wide decision. Documented, not fixed.
+- `ADDRESS_LIST_ADD_ROLE` / `ADDRESS_LIST_REMOVE_ROLE` live in `RuleAddressSetRolesStorage`, inherited by `RuleAddressSet` (the public layer that enforces them) — **not** by `RuleAddressSetInternal`. Do not move them back into `RuleAddressSetInvariantStorage`: a contract reusing only the internal layer (`IdentityRegistryWhitelist`) would then publish two roles it never checks, and an operator granting one would get no privilege and no signal.
+- `RuleChainlinkPoR` and `RuleMaxTotalSupply` both guard `tokenContract.totalSupply()` with a code-length check plus `try/catch`, returning a restriction code (78 and 51 respectively) rather than reverting, and both validate the token at configuration (non-zero, has code, `totalSupply()` callable). The shared mechanics — the revert-free read and the configuration probe — live in `TokenSupplyReader`; each rule supplies its own token via the `_supplyToken()` hook (so the base holds **no storage** and cannot reorder any rule's slots) and raises its own named errors. Do not move the `require`s into the base: three distinct configuration failures deliberately keep three distinct per-rule errors. The ERC-1404 views MUST NOT revert — never call `totalSupply()` unguarded on a read path. Neither rule re-checks `code.length` at read time: `try/catch` cannot catch a call to a codeless address (the ABI decoder fails in the caller's frame, outside `catch`'s reach -- **not** `EXTCODESIZE`, which solc >= 0.8.10 skips when return data is expected), but the setters require code and EIP-6780 (Cancun) makes that permanent, so the check would be unreachable. This is a **deployment precondition** (Cancun or later, `foundry.toml` targets `prague`), documented in each rule doc rather than enforced at runtime — do not re-add the guard unless targeting a pre-Cancun chain. `decimals()` stays optional on the token; `totalSupply()` is mandatory. The two rules use *different* constant names for the same idea (`CODE_TOTAL_SUPPLY_UNAVAILABLE` vs `CODE_SUPPLY_ORACLE_UNAVAILABLE`) because `HelperContract` inherits both invariant-storage contracts and identical identifiers would clash.
+- `RuleChainlinkPoR` accepts `tokenDecimals == 0`. Chainlink's `SecureMintPolicy` requires 1–18, but CMTAT equity tokens report 0 decimals, so the lower bound was dropped. Do not re-add it.
diff --git a/README.md b/README.md
index dedd943f..f07ee3dd 100644
--- a/README.md
+++ b/README.md
@@ -1,1710 +1,211 @@
# RuleEngine - Rules
-**Rules** is a collection of on-chain compliance and transfer-restriction rules designed for use with the [CMTA RuleEngine](https://github.com/CMTA/RuleEngine) and the [CMTAT token standard](https://github.com/CMTA/CMTAT).
+**Rules** is a collection of on-chain compliance and transfer-restriction rules for security tokens built on
+the [CMTAT token standard](https://github.com/CMTA/CMTAT) and the [CMTA RuleEngine](https://github.com/CMTA/RuleEngine), including [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643)-compatible tokens.
-Each rule can be used **standalone**, directly plugged into a CMTAT token, **or** managed collectively via a RuleEngine.
-
-The **RuleEngine** is an external smart contract that applies transfer restrictions to security tokens such as **CMTAT** or [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643)-compatible tokens through a RuleEngine.
-Rules are modular validator contracts that the `RuleEngine` or `CMTAT` compatible token can call on every transfer to ensure regulatory and business-logic compliance.
-
-**Current package version:** `v0.4.0` (contracts report `version()` → `"0.4.0"`). Built against CMTAT `v3.3.0-rc1` and RuleEngine `v3.0.0-rc4`; see [Compatibility](#compatibility) for the supported range.
+Each rule enforces one transfer restriction. A rule can be plugged **directly** into a token, or several can be composed behind a **RuleEngine**.
> This project has not undergone an audit and is provided as-is without any warranties.
-## Table of Contents
-
-- [Schema](#schema)
-- [Overview](#overview)
-- [Compatibility](#compatibility)
-- [Specifications](#specifications)
-- [Architecture](#architecture)
-- [Types of Rules](#types-of-rules)
-- [Quick Start](#quick-start)
-- [Deployment Guide](#deployment-guide)
-- [Rules details](#rules-details)
-- [Access Control](#access-control)
-- [Toolchains and Usage](#toolchains-and-usage)
-- [API](#api)
-- [Security](#security)
-- [Intellectual property](#intellectual-property)
-
-## Schema
-
-- Using rules with CMTAT and ERC-3643 tokens through a [RuleEngine](https://github.com/CMTA/RuleEngine)
-
-
-
-- Using a rule directly with CMTAT and ERC-3643 tokens
-
-
-
-## Overview
-
-### Key Concepts
-
-- **Rules are controllers** that validate or modify token transfers.
-- They can be applied:
- - Directly on **CMTAT** (no RuleEngine required), **or**
- - Through the [**RuleEngine**](https://github.com/CMTA/RuleEngine) (for multi-rule orchestration).
-- Rules enforce conditions such as:
- - Whitelisting / blacklisting
- - Sanctions checks
- - Multi-party operator-managed lists
- - Conditional approvals
- - Arbitrary compliance logic
-
-### Integration modes
-
-A rule can be consumed in three ways. All three call the same rule contract; they differ only in who calls it and how much of the compliance interface is required.
-
-| Mode | Caller | What the rule must implement | When to use |
-| --- | --- | --- | --- |
-| **Direct CMTAT rule** | A CMTAT token calls the rule directly (no RuleEngine) | `IRuleEngine` (`canTransfer` + `transferred`, including the spender-aware overload) | A single rule is enough; no multi-rule orchestration needed |
-| **RuleEngine-managed rule** | A `RuleEngine` aggregates one or more rules and calls each on every transfer | `IRule` (`IRuleEngine` + `canReturnTransferRestrictionCode`) | Several rules must be combined, ordered, or share restriction codes |
-| **ERC-3643 through RuleEngine** | An ERC-3643 token drives `created` / `destroyed` / transfer hooks on a RuleEngine, which forwards them to the rules | Rules as above; the **RuleEngine** implements the full ERC-3643 `ICompliance` | The token is ERC-3643 and needs full `ICompliance` — a standalone rule cannot back an ERC-3643 token directly |
-
-Interface details for each mode are documented under [Architecture](#architecture); full signatures live in the [API](#api) reference.
-
## Compatibility
-| Component | Compatible Versions |
-| ---------------- | ---------------------------------------------------------- |
-| **Rules v0.4.0** | CMTAT ≥ v3.0.0 (tested against v3.3.0-rc1)
RuleEngine v3.0.0-rc4 |
-
-Spender-aware paths (e.g. `RuleMintAllowance`) rely on the 4-argument `canTransferFrom` / `transferred(spender, from, to, value)` callbacks, which require a CMTAT / RuleEngine that forwards the spender to the rule; this repository is validated against CMTAT `v3.3.0-rc1`. The other rules only use the 3-argument path and work across the full CMTAT ≥ v3.0.0 range.
-
-Each Rule implements the interface `IRuleEngine` defined in CMTAT.
-
-This interface declares the ERC-3643 functions `transferred` (read-write) and `canTransfer` (read-only) with several other functions related to [ERC-1404](https://github.com/ethereum/eips/issues/1404), [ERC-7551](https://ethereum-magicians.org/t/erc-7551-crypto-security-token-smart-contract-interface-ewpg-reworked/25477) and [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643).
-
-## Specifications
-
-### ERC-3643
-
-Each rule implements the following functions from the ERC-3643 `ICompliance` interface
-
-```solidity
-function canTransfer(address _from, address _to, uint256 _amount) external view returns (bool);
-function transferred(address _from, address _to, uint256 _amount) external;
-```
-
-However, contrary to the RuleEngine, the whole interface is currently not implemented (e.g. `created` and `destroyed`) and as a result, the rule cannot directly support ERC-3643 token.
-
-The alternative to use a Rule with an ERC-3643 token is through the RuleEngine, which implements the whole `ICompliance` interface.
-
-The diagram below shows the recommended integration: the ERC-3643 token drives transfer, mint (`created`) and burn (`destroyed`) compliance hooks on the RuleEngine, which forwards them to the rules. A rule used on its own only implements `canTransfer` + `transferred`, so it cannot back an ERC-3643 token directly.
-
-
-
-_Diagram source: doc/img/readme-erc3643-integration.puml._
-
-### ERC-721/ERC-1155
-
-To improve compatibility with [ERC-721](https://eips.ethereum.org/EIPS/eip-721) and [ERC-1155](https://eips.ethereum.org/EIPS/eip-1155), most validation rules implement the interface `IERC7943NonFungibleComplianceExtend` which includes compliance functions with the `tokenId` argument. Operation rules (such as `RuleConditionalTransferLight`) are ERC-20 only and do not expose the ERC-721/1155 interfaces. `RuleMaxTotalSupply` is ERC-20 only as well and does not expose ERC-721/1155 interfaces.
-
-While no rules currently apply restriction on the token id, the validation interfaces can be used to implement flexible restriction on ERC-721 or ERC-1155 tokens.
+| Rules | Contracts report `version()` | CMTAT | RuleEngine | OpenZeppelin |
+| --- | --- | --- | --- | --- |
+| **v0.5.0** (current) | `"0.5.0"` | **≥ v3.0.0**, validated against `v3.3.0-rc3` | `v3.0.0-rc5` | `v5.7.0` |
-```solidity
-// IERC7943NonFungibleCompliance interface
-// Read-only functions
-function canTransfer(address from, address to, uint256 tokenId, uint256 amount) external view returns (bool allowed)
+One rule needs more than the baseline, because it reads the **spender** the token forwards on mint:
-// IERC7943NonFungibleComplianceExtend interface
-// Read-only functions
-function detectTransferRestriction(address from, address to, uint256 tokenId, uint256 amount) external view returns (uint8 code);
-function detectTransferRestrictionFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external view returns (uint8 code);
-function canTransferFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external returns (bool allowed);
-
-// State modifying functions (write)
-function transferred(address from, address to, uint256 tokenId, uint256 value) external;
-function transferred(address spender, address from, address to, uint256 tokenId, uint256 value) external;
-```
+| Rule | Minimum CMTAT | Why |
+| --- | --- | --- |
+| `RuleMintAllowance` | **v3.3** | Debits the minter's quota from the 4-argument `transferred(spender, from, to, value)` / `canTransferFrom`. A token that does not forward the spender cannot drive it. |
+| Every other rule | v3.0.0 | Uses the 3-argument path only. |
-The diagram below shows a non-fungible transfer flowing through the `tokenId`-aware compliance signatures. For validation rules a single `transferred(...)` call both validates and reverts — it internally runs `detectTransferRestrictionFrom` and requires `TRANSFER_OK` — so no separate pre-check is required in the transfer path; the read-only `detectTransferRestriction*` / `canTransfer*` overloads remain available for off-chain queries. The `RuleNFTAdapter` carries the `tokenId` argument but currently delegates to the address-based checks (`from` / `to` / `spender`), so no rule restricts on the token id yet.
+The submodules in `lib/` are pinned to the validated versions (CMTAT `v3.3.0-rc3`, RuleEngine `v3.0.0-rc5`), so
+a `git submodule update --init --recursive` checkout builds and tests against exactly what this release was verified with.
-
+📖 **[Full documentation →](./doc/README.md)** — the complete reference: every rule in detail, the API, access-control model, restriction codes, deployment guide, and security findings. This page is a summary.
-_Diagram source: doc/img/readme-erc721-erc1155-compliance.puml._
+## What a rule does
+A rule answers two questions about a proposed token movement.
+| Path | Functions | Behaviour |
+| --- | --- | --- |
+| **Read** | `detectTransferRestriction`, `canTransfer` (and their `…From` variants) | Views returning an ERC-1404 restriction code (`0` = OK). They **must not revert**. |
+| **Write** | `transferred`, `created`, `destroyed` | Called by the token *after* it decides to move value. A rule **reverts** to block, and may update state. |
## Architecture
-### Naming Conventions
-
-- `*Base` contracts contain core logic without an access-control policy.
-- `*InvariantStorage` contracts group constants, custom errors, and events.
-- `*Common` contracts provide shared helper logic across variants (legacy naming retained for compatibility).
-
-### Directory Layout
-
-- `src/modules/`: reusable modules shared across rules (`AccessControlModuleStandalone`, `MetaTxModuleStandalone`, `VersionModule`).
-- `src/rules/interfaces/`: shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITransferContext`).
-- `src/rules/validation/abstract/`: shared base contracts and invariant storage.
-- `src/rules/validation/abstract/base/`: base contracts with core rule logic (no access control).
-- `src/rules/validation/abstract/core/`: shared adapters/validation helpers.
-- `src/rules/validation/abstract/invariant/`: invariant storage contracts (constants, errors, events).
-- `src/rules/validation/deployment/`: deployable validation rules (concrete contracts).
-- `src/rules/operation/`: read-write (operation) rules that modify state on transfer.
-- `test/`: Foundry tests, one folder per rule.
-- `script/`: deployment scripts.
-
-### Rule - Code list
-
-> It is very important that each rule uses a unique code
-
-Here is the list of codes used by the different rules
-
-| Contract | Constant name | Value |
-| ---------------------------- | ------------------------------------ | ----- |
-| All | TRANSFER_OK (from CMTAT) | 0 |
-| RuleWhitelist | CODE_ADDRESS_FROM_NOT_WHITELISTED | 21 |
-| | CODE_ADDRESS_TO_NOT_WHITELISTED | 22 |
-| | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 23 |
-| | CODE_MINT_NOT_ALLOWED | 24 |
-| | CODE_BURN_NOT_ALLOWED | 25 |
-| | Reserved slot | 26-29 |
-| RuleSanctionList | CODE_ADDRESS_FROM_IS_SANCTIONED | 30 |
-| | CODE_ADDRESS_TO_IS_SANCTIONED | 31 |
-| | CODE_ADDRESS_SPENDER_IS_SANCTIONED | 32 |
-| | Reserved slot | 33-35 |
-| RuleBlacklist | CODE_ADDRESS_FROM_IS_BLACKLISTED | 36 |
-| | CODE_ADDRESS_TO_IS_BLACKLISTED | 37 |
-| | CODE_ADDRESS_SPENDER_IS_BLACKLISTED | 38 |
-| | Reserved slot | 39-45 |
-| RuleConditionalTransferLight | CODE_TRANSFER_REQUEST_NOT_APPROVED | 46 |
-| | Reserved slot | 47-49 |
-| RuleMaxTotalSupply | CODE_MAX_TOTAL_SUPPLY_EXCEEDED | 50 |
-| | Reserved slot | 51-54 |
-| RuleIdentityRegistry | CODE_ADDRESS_FROM_NOT_VERIFIED | 55 |
-| | CODE_ADDRESS_TO_NOT_VERIFIED | 56 |
-| | CODE_ADDRESS_SPENDER_NOT_VERIFIED | 57 |
-| | Reserved slot | 58-59 |
-| RuleERC2980 | CODE_ADDRESS_FROM_IS_FROZEN | 60 |
-| | CODE_ADDRESS_TO_IS_FROZEN | 61 |
-| | CODE_ADDRESS_SPENDER_IS_FROZEN | 62 |
-| | CODE_ADDRESS_TO_NOT_WHITELISTED | 63 |
-| | CODE_MINT_NOT_ALLOWED | 64 |
-| | CODE_BURN_NOT_ALLOWED | 65 |
-| RuleSpenderWhitelist | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 66 |
-| | Reserved slot | 67-69 |
-| RuleMintAllowance | CODE_MINTER_ALLOWANCE_EXCEEDED | 70 |
-| | Reserved slot | 71-74 |
-
-Note:
-
-- The CMTAT already uses the code 0-6 and the code 7-12 should be left free to allow further additions in the CMTAT.
-- If you decide to create your own rules, we encourage you to use code > 100 to leave free the other restriction codes for future rules added in this project.
-- Reserved slots are intentionally left unused for future rule expansion (maximum of 3 per rule).
-- New rule code blocks should start at codes ending in `1` or `6` (e.g., `21`, `26`), leaving the remaining codes in the previous block for that prior rule’s reserved slots.
-- Current allocations are legacy; new rules should follow the start-at-1-or-6 policy without changing existing codes.
-
-### Rules as Standalone Compliance Contracts
-
-Every rule implements the minimal interface expected by **CMTAT**, notably:
-
-```solidity
-function transferred(address from, address to, uint256 value)
-function transferred(address spender, address from, address to, uint256 value)
-```
-
-This makes rules directly pluggable into CMTAT without any intermediary RuleEngine.
-
-### Transfer Context Helper
-
-Rules also expose an optional unified entrypoint using `MultiTokenTransferContext` / `FungibleTransferContext` (see `ITransferContext`) to pass a single struct instead of multiple arguments. This is a helper API inspired by [TokenF](https://github.com/dl-tokenf/contracts) and does not replace the standard ERC-3643 / RuleEngine interfaces. Validation rules generally expose both the non-fungible and fungible variants; `RuleConditionalTransferLight` and `RuleMaxTotalSupply` expose only the fungible variant.
-
-Two struct variants are available:
-
-```solidity
-// For ERC-721 / ERC-1155 (includes tokenId)
-struct MultiTokenTransferContext {
- bytes4 selector; // function selector of the original call
- address sender; // operator/spender (address(0) for direct transfers)
- address from; // token sender
- address to; // token recipient
- uint256 value; // amount transferred
- uint256 tokenId; // token id (non-fungible)
- bytes data; // Optional token-provided metadata for rules
-}
-
-// For ERC-20 (no tokenId)
-struct FungibleTransferContext {
- bytes4 selector; // function selector of the original call
- address sender; // operator/spender (address(0) for direct transfers)
- address from; // token sender
- address to; // token recipient
- uint256 value; // amount transferred
- bytes data; // Optional token-provided metadata for rules
-}
-```
-
-Both structs are passed to `transferred(MultiTokenTransferContext calldata ctx)` or `transferred(FungibleTransferContext calldata ctx)`. If `ctx.sender` is non-zero, the spender-aware path is used internally; otherwise the standard two-party path is used. The `data` field is reserved for optional token-provided metadata that rules can interpret.
+Two integration topologies, and the choice determines what `msg.sender` is inside a rule:
-### Using Rules via RuleEngine
+
-When used through the RuleEngine, a rule must also implement:
+_Diagram source: [`doc/schema/architecture-topologies.puml`](./doc/schema/architecture-topologies.puml)._
-```solidity
-interface IRule is IRuleEngine {
- function canReturnTransferRestrictionCode(uint8 restrictionCode)
- external
- view
- returns (bool);
-}
-```
-
-The RuleEngine can then:
-
-- Aggregate multiple rules
-- Execute them sequentially on each transfer
-- Return restriction codes
-- Mutate rule state (operation rules)
-
-The same rule can also be plugged **directly** into a CMTAT token (see [Rules as Standalone Compliance Contracts](#rules-as-standalone-compliance-contracts) above): the direct-CMTAT path only requires `IRuleEngine`, while the RuleEngine-managed path additionally requires `IRule`. Full signatures for both interfaces are documented in the [API](#api) reference (`IRuleEngine`, `IERC1404Extend`, `IERC7551Compliance`, `IERC3643IComplianceContract`).
+Behind a RuleEngine the engine returns the **first non-zero** restriction code, so rule order decides which
+code a rejection reports. In direct mode the rule is installed with `token.setRuleEngine(rule)`.
-## Types of Rules
+**Operation rules** keep state keyed on the caller, so the two are not interchangeable for them: `RuleConditionalTransferLightMultiToken` is direct-binding only, and `RuleMintAllowance` requires the engine path. Validation rules work under either.
-There are two categories of rules: validation rules (read-only) and operation rules (read-write).
+### Layout
-### Which rule should I use?
-
-| Need | Rule |
+| Path | Contents |
| --- | --- |
-| Only approved holders can send/receive | `RuleWhitelist` |
-| Combine several whitelists (OR logic) | `RuleWhitelistWrapper` |
-| Restrict `transferFrom` operators (spenders) | `RuleSpenderWhitelist` |
-| Block known bad addresses | `RuleBlacklist` |
-| Block sanctioned addresses (Chainalysis oracle) | `RuleSanctionsList` |
-| Cap total token supply | `RuleMaxTotalSupply` |
-| Require identity-registry verification (ERC-3643) | `RuleIdentityRegistry` |
-| ERC-2980 Swiss compliance (whitelist + frozenlist) | `RuleERC2980` |
-| Require operator approval per transfer | `RuleConditionalTransferLight` |
-| Per-transfer approval across several **directly-bound** tokens (not behind a RuleEngine) | `RuleConditionalTransferLightMultiToken` |
-| Limit mint quota per minter | `RuleMintAllowance` |
-
-Each rule is also available in `Ownable2Step` and `AccessControl` variants; see [Choosing a Rule Variant](#choosing-a-rule-variant). Stateful rules have binding constraints — see the [Binding model](#binding-model) table.
-
-### How rules differ (semantics comparison)
-
-Rules do **not** all treat the spender, mint/burn, or an unset oracle the same way. The full side-by-side table — who each rule screens (`from` / `to` / spender on `transferFrom` / mint / burn), how it behaves when its oracle/registry is unset, whether it is stateful, and which pre-flight view is authoritative — is in **[RULE_SEMANTICS.md](./doc/technical/RULE_SEMANTICS.md)**. The differences most likely to surprise an integrator:
-
-- **Spender on mint.** `RuleWhitelist` / `RuleWhitelistWrapper` / `RuleSpenderWhitelist` **exempt** the minter; `RuleBlacklist` / `RuleSanctionsList` **screen** it (deny-list, by design); `RuleIdentityRegistry` also screens it, so the minter must itself be identity-verified; `RuleMintAllowance` **debits the minter's quota**.
-- **Unset oracle/registry.** `RuleSanctionsList` (oracle unset) and `RuleIdentityRegistry` (registry unset) **fail open** — all transfers pass. An empty `RuleWhitelistWrapper` **fails closed**.
-- **Authoritative pre-flight view.** For `RuleMintAllowance`, `canTransfer` is not authoritative — use `canTransferFrom`. For `RuleConditionalTransferLightMultiToken`, `detectTransferRestriction` is `msg.sender`-dependent.
-
-### Validation Rules (Read-Only)
-
-Validation rules only read blockchain state — they never modify it during a transfer. They implement `transferred()` as a `view` function: it re-runs the same restriction check and reverts if the transfer would be blocked, but writes nothing to storage.
-
-All validation rules implement `IRuleEngine` to be usable both standalone (plugged directly into CMTAT) and via the RuleEngine.
-
-Available validation rules: `RuleWhitelist`, `RuleWhitelistWrapper`, `RuleSpenderWhitelist`, `RuleBlacklist`, `RuleSanctionsList`, `RuleMaxTotalSupply`, `RuleIdentityRegistry`, `RuleERC2980`.
-
- A community made project, [RuleSelf](https://github.com/rya-sge/ruleself), which uses [Self](https://self.xyz), a zero-knowledge identity is also available but is not developed or maintained by CMTA.
-
-### Operation Rules (Read-Write)
-
-Operation rules modify blockchain state during transfer execution. Their `transferred()` function is state-mutating: it consumes or updates stored data as part of the transfer flow.
-
-Available operation rules: `RuleConditionalTransferLight`, `RuleConditionalTransferLightMultiToken`, `RuleMintAllowance`.
-
-A full-featured variant, `RuleConditionalTransfer`, is maintained as a separate experimental repository at [CMTA/RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer).
-
-## Quick Start
-
-```bash
-# 1. Clone the repository
-git clone
-cd Rules
-
-# 2. Install Foundry (if not already installed)
-# https://book.getfoundry.sh/getting-started/installation
-
-# 3. Install submodule dependencies
-forge install
-
-# 4. Compile
-forge build
+| `src/rules/validation/` | Read-only rules: no state change during a transfer |
+| `src/rules/operation/` | Read-write rules: mutate state on transfer |
+| `src/registry/` | Fills a token's **identity registry** slot, not its compliance slot (`IdentityRegistryWhitelist`). Not rules |
+| `src/modules/` | Reusable modules (access control, meta-tx, versioning) |
+| `script/` | Deployment scripts |
+| `test/` | Foundry tests, one folder per rule |
-# 5. Run tests
-forge test
-```
-
-## Deployment Guide
-
-> ⚠️ **Before production deployment:** this project has [not undergone an audit](#ruleengine---rules). Review the unaudited status, configure roles with least privilege (grant only the roles each operator needs, and prefer the `Ownable2Step` variants for single-owner setups), and run an end-to-end transfer test on the target token setup.
-
-1. Deploy the rule contract(s) with the desired admin and optional module addresses.
-2. Configure the rule state and roles, including whitelist/blacklist entries and oracle or registry addresses.
-3. Add rules to the RuleEngine, or set the rule directly on the CMTAT token.
-4. Verify the transfer flow end-to-end with a small test transfer before enabling production flows.
-
-Deployment scripts:
-- `script/DeployCMTATWithWhitelist.s.sol`
-- `script/DeployCMTATWithBlacklist.s.sol`
-- `script/DeployCMTATWithBlacklistAndSanctionsList.s.sol` — CMTAT + RuleEngine with blacklist and sanctions rules
-
-### Choosing a Rule Variant
-
-Several rules are available in multiple access-control variants. Use the simplest one that fits your needs:
-
-- `AccessControl` variants: use when you need multi-operator roles or delegated administration.
-- `Ownable2Step` variants: use when you want a safer two-step ownership transfer.
-
-### Validation Rules (Read-Only)
-
-- Cannot modify blockchain state during transfers.
-- Used for simple eligibility checks.
-- Examples:
- - Whitelist
- - Whitelist Wrapper
- - Spender Whitelist
- - Blacklist
- - Sanction list (Chainalysis)
- - ERC-2980 (whitelist + frozenlist)
-
-### Operation Rules (Read-Write)
-
-- Can update state during transfer calls.
-- Example:
- - Conditional Transfer (approval-based)
-
-## Rules details
-
-### Summary tab
-
-| Rule | Type
[read-only / read-write] | ERC-721 / ERC-1155 | ERC-3643 via RuleEngine / CMTAT path * | Security Audit planned in the roadmap | Description |
-| ------------------------------------------------------------ | ------------------------------------ | ------------------ | -------- | ------------------------------------- | ------------------------------------------------------------ |
-| RuleWhitelist | Read-only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a whitelist. |
-| RuleWhitelistWrapper | Read-Only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a group of whitelist rules managed by different operators. |
-| RuleBlacklist | Read-Only | ✔ | ✔ | ✔ | This rule can be used to forbid transfer from/to addresses in the blacklist |
-| RuleSanctionList | Read-Only | ✔ | ✔ | ✔ | The purpose of this contract is to use the oracle contract from [Chainalysis](https://go.chainalysis.com/chainalysis-oracle-docs.html) to forbid transfer from/to an address included in a sanctions designation (US, EU, or UN). |
-| RuleMaxTotalSupply | Read-Only | ✘ | ✔ | ✔ | This rule limits minting so that the total supply never exceeds a configured maximum. |
-| RuleIdentityRegistry | Read-Only | ✔ | ✔ | ✔ | This rule checks the ERC-3643 Identity Registry for transfer participants when configured. |
-| RuleSpenderWhitelist | Read-Only | ✔ | ✔ | ✔ | This rule blocks `transferFrom` when the spender is not in the whitelist. Direct transfers are always allowed. |
-| RuleERC2980 | Read-Only | ✔ | ✔ | ✔ | ERC-2980 Swiss Compliant rule combining a whitelist (recipient-only) and a frozenlist (blocks sender, recipient, and spender for `transferFrom`). Frozenlist takes priority over whitelist. |
-| RuleConditionalTransferLight | Read-Write | ✘ | ✔ | ✔ | This rule requires that transfers have to be approved by an operator before being executed. Each approval is consumed once and the same transfer can be approved multiple times. |
-| RuleConditionalTransferLightMultiToken | Read-Write | ✘ | ✔ | ✔ | Multi-token variant of ConditionalTransferLight. Approvals are token-scoped with key `(token, from, to, value)` so one token cannot consume another token's approvals. |
-| RuleMintAllowance | Read-Write | ✘ | Partial † | ✔ | Enforces a per-minter mint quota managed by an operator; each mint reduces the minter's allowance. Regular transfers and burns are not restricted. |
-| [RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer) (external) | Read-Write | ✘ | ✔ | ✘
(experimental rule) | Full-featured approval-based transfer rule implementing Swiss law *Vinkulierung*. Supports automatic approval after three months, automatic transfer execution, and a conditional whitelist for address pairs that bypass approval. Maintained in a separate repository. |
-| [RuleSelf](https://github.com/rya-sge/ruleself) (community) | — | ✘ | — | ✘
(community project) | Use [Self](https://self.xyz), a zero-knowledge identity solution to determine which is allowed to interact with the token.
Community-maintained rule project. Not developed or maintained by CMTA. |
-
-All rules implement the CMTAT rule interfaces needed by their supported transfer paths. Some operation rules require the spender-aware callback, as documented in their rule-specific notes.
+Each rule splits into a `*Base` contract holding the logic and a deployable variant supplying the
+access-control policy, in either an `AccessControl` or an `Ownable2Step` flavour.
-* A checkmark in this column means the rule enforces compliance for ERC-3643 tokens **through a RuleEngine or the CMTAT transfer path** — it does **not** mean the rule is itself a full ERC-3643 `ICompliance` contract. A standalone rule implements only `canTransfer` + `transferred`, so it cannot back an ERC-3643 token directly; use it through a RuleEngine, which implements the full `ICompliance` interface (see [Integration modes](#integration-modes)).
+## The rules
-† `RuleMintAllowance` is **Partial**: it does not advertise the full ERC-3643 `ICompliance` interface via ERC-165 because its per-minter mint quota requires the spender-aware mint callback to identify the minter, which the 3-argument ERC-3643 mint callback cannot provide.
-
-### Technical documentation
-
-Detailed technical documentation for each rule is available in [`doc/technical/`](doc/technical/):
-
-| Rule | Document |
-| ---- | -------- |
-| RuleWhitelist | [RuleWhitelist.md](./doc/technical/RuleWhitelist.md) |
-| RuleWhitelistWrapper | [RuleWhitelistWrapper.md](./doc/technical/RuleWhitelistWrapper.md) |
-| RuleBlacklist | [RuleBlacklist.md](./doc/technical/RuleBlacklist.md) |
-| RuleSanctionsList | [RuleSanctionList.md](./doc/technical/RuleSanctionList.md) |
-| RuleMaxTotalSupply | [RuleMaxTotalSupply.md](./doc/technical/RuleMaxTotalSupply.md) |
-| RuleIdentityRegistry | [RuleIdentityRegistry.md](./doc/technical/RuleIdentityRegistry.md) |
-| RuleSpenderWhitelist | [RuleSpenderWhitelist.md](./doc/technical/RuleSpenderWhitelist.md) |
-| RuleERC2980 | [RuleERC2980.md](./doc/technical/RuleERC2980.md) |
-| RuleConditionalTransferLight | [RuleConditionalTransferLight.md](./doc/technical/RuleConditionalTransferLight.md) |
-| RuleConditionalTransferLightMultiToken | [RuleConditionalTransferLightMultiToken.md](./doc/technical/RuleConditionalTransferLightMultiToken.md) |
-| RuleMintAllowance | [RuleMintAllowance.md](./doc/technical/RuleMintAllowance.md) |
-
-### Operational Notes
-
-#### Binding model
-
-Stateful (operation) rules restrict which caller may consume their state via `transferred()`, so the target must be explicitly bound with `bindToken`. The binding model differs per rule:
-
-| Rule | Binding model | Notes |
+| Rule | Enforces | Codes |
| --- | --- | --- |
-| `RuleConditionalTransferLight` | Single token **+ optional RuleEngine** | Two independent bindings: `bindToken(token)` sets the ERC-20 this rule acts on, `bindRuleEngine(engine)` authorises the engine to call `transferred`. `transferred` accepts either. Behind a RuleEngine, bind **both** — then `approveAndTransferIfAllowed` works too. Rebind only after `unbindToken` / `unbindRuleEngine`. See [Binding: token vs RuleEngine](./doc/technical/RuleConditionalTransferLight.md#binding-token-vs-ruleengine) |
-| `RuleConditionalTransferLightMultiToken` | **Multiple direct tokens only** | Approvals keyed by `(token, from, to, value)` but *consumed* under `msg.sender`. ⚠️ **Do not add this rule to a `RuleEngine`** — bind each token directly (`CMTAT.setRuleEngine(rule)`). Behind an engine the rule either reverts or silently loses all per-token isolation; see [Deployment topology](./doc/technical/RuleConditionalTransferLightMultiToken.md#deployment-topology--why-a-ruleengine-does-not-work) |
-| `RuleMintAllowance` | Single RuleEngine/token | Bind the RuleEngine address in a CMTAT + RuleEngine setup; rebind only after `unbindToken`. Requires the spender-aware mint callback |
-
-Validation (read-only) rules have no binding requirement: they hold no per-transfer state and can be shared across tokens and RuleEngines freely.
-
-#### RuleIdentityRegistry
-
-- `RuleIdentityRegistry`: allows burns (`to == address(0)`) even if the sender is not verified. This matters only if the token allows self-burn.
-- `RuleIdentityRegistry`: can be disabled with `clearIdentityRegistry()`, which allows all transfers to pass this rule.
-- `RuleIdentityRegistry`: constructor accepts `address(0)` to start in a disabled state.
-
-#### RuleSanctionsList
-
-- `RuleSanctionsList`: rejects zero address in `setSanctionListOracle`. Use `clearSanctionListOracle()` to disable checks.
-- `RuleSanctionsList`: constructor accepts `address(0)` to start in a disabled state.
-
-#### RuleMaxTotalSupply
-
-- `RuleMaxTotalSupply`: trusts the configured `tokenContract` to return an accurate `totalSupply()`.
-- `RuleMaxTotalSupply`: does not allow clearing the token contract; disable the rule by removing it from the RuleEngine or token.
-
-#### RuleWhitelistWrapper
-
-- `RuleWhitelistWrapper`: requires child rules that implement `IAddressList`. A wrapper with zero rules rejects all transfers (fail-closed).
-- **Scan cost is paid on every transfer, by the transferring user.** The wrapper makes one external `STATICCALL` per child rule — **~8.8k gas each** — and the scan runs during transfer *execution*, not only in views. At the default cap of 10 children the worst case is ~90k gas per transfer (~121k with `checkSpender = true`).
-- **Two amplifiers:** a transfer that is going to be *rejected* never resolves its target addresses, so it never early-exits and always scans **all** children — the failing path is the most expensive one. And `checkSpender = true` adds a third address that must also be found, lowering the early-exit rate.
-- **Operator responsibility:** keep the child list at or below the default `maxRules = 10`, and order children by expected hit rate so the early exit fires sooner. The scan is linear (~8.8k gas/child, measured flat up to 200 children), so `setMaxRules` accepts any non-zero value and raising the cap to 100 makes every transfer cost ~884k gas. That is a permanent tax on holders rather than a broken token — transfers still fit in a block until ~3,400 children — but it cannot be undone for transfers already paid. Full cost model and guidance: [RuleWhitelistWrapper.md](./doc/technical/RuleWhitelistWrapper.md#gas-cost-of-the-child-rule-scan).
-
-#### RuleSpenderWhitelist
-
-- `RuleSpenderWhitelist`: only checks the spender in `transferFrom`; direct transfers always pass this rule.
-
-#### RuleERC2980
-
-- `RuleERC2980`: frozenlist takes priority over whitelist; an address that is both whitelisted and frozen is rejected.
-- `RuleERC2980`: a frozen address acting as `transferFrom` spender is also blocked (code 62), even if `from` and `to` are not frozen.
-- `RuleERC2980`: sender (`from`) does not need to be whitelisted; only recipient (`to`) must be whitelisted.
-
-#### RuleConditionalTransferLight
-
-- `RuleConditionalTransferLight`: approvals are keyed by `(from, to, value)` and are not nonce-based.
-- `RuleConditionalTransferLight`: `approveAndTransferIfAllowed` approves and immediately executes `transferFrom` when this rule has allowance; it assumes token callback to `transferred()`.
-- `RuleConditionalTransferLight`: `transferred()` is restricted to the single token bound via `bindToken`; second bind reverts with `RuleConditionalTransferLight_TokenAlreadyBound` until `unbindToken`.
-- `RuleConditionalTransferLight`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`.
-
-#### RuleConditionalTransferLightMultiToken
-
-- `RuleConditionalTransferLightMultiToken`: approvals are keyed by `(token, from, to, value)` and are not nonce-based.
-- `RuleConditionalTransferLightMultiToken`: operator functions are token-scoped (`approveTransfer(token, ...)`, `cancelTransferApproval(token, ...)`, `approvedCount(token, ...)`, `approveAndTransferIfAllowed(token, ...)`).
-- `RuleConditionalTransferLightMultiToken`: execution is restricted to bound tokens; only the calling bound token can consume approvals for its own key space.
-- `RuleConditionalTransferLightMultiToken`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`.
-- `RuleConditionalTransferLightMultiToken`: with a shared `RuleEngine`, the caller seen by the rule is the engine address (not the underlying token). In that topology, token-scoped approvals are not visible unless approvals are keyed to the engine address, which is not per-token scoping.
-- **Warning**: `RuleConditionalTransferLightMultiToken` supports several tokens when integrated directly with each token contract. It must not be used for per-token approval isolation through a shared `RuleEngine`.
-
-#### General notes
-
-- All validation rules: read-only rules still implement `transferred()` for ERC-3643 and RuleEngine compatibility, but do not change state.
-- All AccessControl variants: use `onlyRole(ROLE)` in `_authorize*()` and mark internal helpers `virtual`.
-- All AccessControl variants: use `AccessControlEnumerable`, so role members can be enumerated with `getRoleMember` / `getRoleMemberCount`; default admin is treated as having all roles via `hasRole`, but may not appear in role member lists unless explicitly granted.
-- All meta-tx-enabled rules: `forwarderIrrevocable` is accepted as-is (including `address(0)`) and is not validated against ERC-165 because some forwarders do not implement it.
-- All rules: implement `IERC3643Version` via `VersionModule` and expose `version()` returning `"0.4.0"`.
-
-### Read-only (validation) rule
-
-Currently, there are eight validation rules: whitelist, whitelist wrapper, spender whitelist, blacklist, sanctions list, max total supply, identity registry, and ERC-2980.
-
-#### Whitelist
-
-Only whitelisted addresses may hold or receive tokens.
- Transfers are rejected if:
-
-- `from` is not whitelisted
-- `to` is not whitelisted
-
-The rule is read-only: it only checks stored state.
-- Constructor parameter `allowMintBurn` sets **both** `allowMint` and `allowBurn` — the common case. Use `setAllowMint(bool)` / `setAllowBurn(bool)` afterwards for independent control (e.g. permanently close issuance while keeping redemptions open).
-- Mint/burn permission is an **explicit flag**, never list membership of `address(0)`. The zero address can never enter the list (`addAddress(address(0))` reverts), so `isVerified(address(0))` / `contains(address(0))` stay `false`, as ERC-3643 requires.
-- The flag gates the **operation only**: a permitted mint still requires a whitelisted *recipient*; a permitted burn still requires a whitelisted *sender*.
-- Blocked mint/burn return dedicated codes `24` / `25` (not the misleading "sender not whitelisted").
-
-**Example**
-
-During a transfer, this rule, called by the RuleEngine, will check if the address concerned is in the list, applying a read operation on the blockchain.
-
-**Usage scenario**
-
-An operator configures CMTAT to use `RuleWhitelist`. The issuer tries to mint to Alice via `mint`/`transfer` and the token calls `detectTransferRestriction`/`transferred`; Alice is not listed so the call reverts. The operator calls `addAddress(Alice)`. The issuer retries the mint and it succeeds.
-
-
-
-#### Spender whitelist
-
-This rule only checks `transferFrom` spender authorization:
-
-- Direct transfers (`transfer`) are always allowed by this rule.
-- `transferFrom` is rejected when `spender` is not listed.
-- Restriction code: `66` (`CODE_ADDRESS_SPENDER_NOT_WHITELISTED`).
-
-**Usage scenario**
-
-The operator deploys `RuleSpenderWhitelist` and sets it in the token or `RuleEngine`. Alice calls `transfer` to Bob and it passes this rule. Bob then tries `transferFrom(Alice, Bob, amount)` and it is rejected until the operator calls `addAddress(Bob)` (or whichever spender account should be authorized).
-
-
-
-#### Whitelist wrapper
-
-Allows independent whitelist groups managed by different operators.
-
-- Each operator manages a dedicated whitelist.
-- A transfer is allowed only if both addresses belong to *at least one* operator-managed list.
-- Enables multi-party compliance
-
-**Usage scenario**
-
-Two operators maintain separate whitelists using `addRule`/`setRules` and each child rule’s `addAddress`. A transfer between Alice and Bob is allowed if at least one child whitelist returns `true` for both via `areAddressesListed`; otherwise `detectTransferRestriction` rejects it.
-
-
-
-##### Architecture
-
-This rule inherits from `RuleEngineValidationCommon`. Thus the whitelist rules are managed with the same architecture and code than for the ruleEngine. For example, rules are added with the functions `setRules` or `addRule`.
-
-
-
-
-
-
-
-#### Blacklist
-
-Opposite of whitelist:
-
-- Transfer fails if **either** address is blacklisted.
-
-**Usage scenario**
-
-The operator sets `RuleBlacklist` on the token. The issuer tries to transfer to Bob; `detectTransferRestriction` passes. The operator calls `addAddress(Bob)`. A subsequent transfer to Bob is rejected until `removeAddress(Bob)` is called.
-
-
-
-#### ERC-2980 (Whitelist + Frozenlist)
-
-Implements the [ERC-2980](https://eips.ethereum.org/EIPS/eip-2980) Swiss Compliant Asset Token transfer restriction using two independent address lists managed in a single rule:
-
-- **Whitelist**: only whitelisted addresses may *receive* tokens. Senders do not need to be whitelisted and may freely transfer tokens they already hold.
-- **Frozenlist**: frozen addresses are completely blocked — they can neither send nor receive tokens. Additionally, a frozen address acting as a `transferFrom` spender will have the transfer rejected (code 62), even if `from` and `to` are not frozen.
-- **Priority**: frozenlist is checked first. If `from`, `to`, or `spender` is frozen, the transfer is rejected regardless of whitelist membership.
-- **Mint/burn handling**: governed by the explicit `allowMint` / `allowBurn` flags, never by whitelisting `address(0)`. The zero address can never enter either list, so the **mandatory ERC-2980 getters** `whitelist(address(0))` / `frozenlist(address(0))` always return `false`.
- - `allowMintBurn = false` (default-safe): mint is refused with code **64**, burn with code **65**.
- - `allowMintBurn = true`: both permitted. A permitted mint still requires the recipient to be whitelisted and not frozen; a permitted burn still requires the sender not to be frozen.
- - Independently settable afterwards via `setAllowMint(bool)` / `setAllowBurn(bool)`.
-- Constructors:
- - `RuleERC2980(address admin, address forwarderIrrevocable, bool allowMintBurn)`
- - `RuleERC2980Ownable2Step(address owner, address forwarderIrrevocable, bool allowMintBurn)`
-
-
-
-Restriction codes:
-
-| Constant | Code | Meaning |
+| `RuleWhitelist` | Transfers only between whitelisted addresses | 21–25 |
+| `RuleReceiverWhitelist` | Receiver only, reproducing ERC-3643 eligibility | 81 |
+| `RuleSpenderWhitelist` | `transferFrom` only when the spender is whitelisted | 66 |
+| `RuleWhitelistWrapper` | Aggregates several whitelists with OR logic | 21–23 |
+| `RuleBlacklist` | Blocks blacklisted participants | 36–38 |
+| `RuleSanctionsList` | Blocks sanctioned addresses via a Chainalysis oracle | 30–32 |
+| `RuleERC2980` | ERC-2980 whitelist plus frozenlist | 60–65 |
+| `RuleIdentityRegistry` | Consults an ERC-3643 identity registry | 55–57 |
+| `RuleMaxTotalSupply` | Caps total supply on mint | 50, 51 |
+| `RuleMaxBalance` | Caps how many tokens one address may hold | 82, 83 |
+| `RuleChainlinkPoR` | Caps minting at Chainlink Proof of Reserve reserves | 75–79 |
+| `RuleConditionalTransferLight` | Requires operator approval per transfer | 46 |
+| `RuleMintAllowance` | Per-minter mint quota | 70 |
+
+Codes must stay unique across rules, since a RuleEngine returns the first non-zero one.
+Per-rule detail is in [`doc/technical/`](./doc/technical/); the semantics that differ between rules (who is screened, mint/burn handling, unset-oracle behaviour) are tabulated in [`RULE_SEMANTICS.md`](./doc/technical/guides/RULE_SEMANTICS.md).
+
+## ERC-3643
+
+An [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643) token has **two** pluggable slots, and this library fills both, from opposite directions.
+
+| Slot | Filled with | Direction |
| --- | --- | --- |
-| `CODE_ADDRESS_FROM_IS_FROZEN` | 60 | Sender is frozen |
-| `CODE_ADDRESS_TO_IS_FROZEN` | 61 | Recipient is frozen |
-| `CODE_ADDRESS_SPENDER_IS_FROZEN` | 62 | Spender is frozen |
-| `CODE_ADDRESS_TO_NOT_WHITELISTED` | 63 | Recipient is not whitelisted |
-| `CODE_MINT_NOT_ALLOWED` | 64 | Minting is disabled (`allowMint == false`) |
-| `CODE_BURN_NOT_ALLOWED` | 65 | Burning is disabled (`allowBurn == false`) |
-
-**Deviation from spec**: the ERC-2980 `Whitelistable` / `Freezable` example interfaces define single-address management functions that return `bool` and do not revert on duplicates or missing entries. This implementation reverts on invalid single-item operations, consistent with the codebase convention. Batch operations remain non-reverting.
-
-**Usage scenario**
-
-The operator deploys `RuleERC2980` and chooses `allowBurn` according to the redemption policy. The issuer whitelists Alice with `addWhitelistAddress(Alice)`. A transfer to Alice succeeds. The compliance officer freezes Bob with `addFrozenlistAddress(Bob)`. Any transfer from or to Bob is now rejected even if Bob was previously whitelisted.
-
-#### Sanction list with Chainalysis
-
-Uses the [Chainalysis](https://www.chainalysis.com/) Oracle to reject transfers involving sanctioned addresses.
-
-- Checks lists for: **US**, **EU**, and **UN** sanctions.
-- Documentation: *Chainalysis Oracle for sanctions screening*
-- If `from` or `to` is sanctioned, transfer is rejected.
-
-The documentation and contract addresses are available here: [Chainalysis oracle for sanctions screening](https://go.chainalysis.com/chainalysis-oracle-docs.html).
-
-
-
-**Example**
-
-During a transfer, if either address (from or to) is in the sanction list of the Oracle, the rule will return false, and the transfer will be rejected by the CMTAT.
-
-**Usage scenario**
-
-The operator sets the Chainalysis oracle with `setSanctionListOracle`. The token’s transfer path calls `detectTransferRestriction`; if the oracle flags `from` or `to`, the transfer is rejected. Calling `clearSanctionListOracle` disables checks.
-
-#### Max total supply
-
-Limits minting so that total supply never exceeds a configured maximum. Transfers and burns are not affected; only mints (`from == address(0)`) are checked.
-
-
-
-**Usage scenario**
-
-The operator deploys `RuleMaxTotalSupply` with `setMaxTotalSupply(1_000_000)` and sets the token with `setTokenContract`. When the issuer mints and `totalSupply + amount` exceeds the limit, `detectTransferRestriction` rejects the mint. Transfers between holders still pass.
-
-#### Identity registry
-
-**ERC-3643 conformant: only the RECEIVER is verified.** The specification mandates exactly one identity check — *"The receiver MUST be whitelisted on the Identity Registry and verified"* — and states that `transferFrom` "works the same way", that `mint` "only require[s] the receiver", and that `burn` "bypasses all checks on eligibility". The **sender**, the **spender** and the **minter** are therefore **not** verified by default.
-
-Checking the sender is deliberately avoided: ERC-3643 screens only the receiver precisely so that an investor whose identity lapses can still **exit their position** by sending to a verified counterparty. Screening the sender would trap them — unable to receive *and* unable to send.
-
-Stricter screening is available as an **explicit opt-in**, never a silent default:
-- `checkSender` — also verify the sender (stricter than ERC-3643).
-- `checkSpender` — also verify the spender on `transferFrom` (stricter than ERC-3643). Mint and burn stay exempt regardless.
-
-Constructors: `RuleIdentityRegistry(address admin, address identityRegistry, bool checkSender, bool checkSpender)` — pass `false, false` for the conformant default. Both flags are settable afterwards via `setCheckSender(bool)` / `setCheckSpender(bool)`.
-
-
-
-**Usage scenario**
-
-The operator calls `setIdentityRegistry(registry)`. The issuer attempts a transfer to Alice; `detectTransferRestriction` consults `isVerified` and rejects if Alice is unverified. After the registry marks Alice verified, the transfer succeeds. Calling `clearIdentityRegistry` disables checks.
-
-### Read-Write (Operation) rule
-
-There are three operation rules available: `RuleConditionalTransferLight`, `RuleConditionalTransferLightMultiToken`, and `RuleMintAllowance`.
-
-#### Conditional transfer (light)
-
-This rule requires that transfers must be approved by an operator before being executed. It hashes `(from, to, value)` to track approvals and allows the same transfer to be approved multiple times. Each successful transfer consumes one approval, applying a write operation on the blockchain. Mints (`from == address(0)`) and burns (`to == address(0)`) are exempt and always pass without requiring approval.
-
-
-
-**Usage scenario**
-
-An operator calls `approveTransfer(from, to, value)`. The compliance manager binds exactly one token with `bindToken(token)`; attempting to bind a second token reverts. The token calls `detectTransferRestriction` (passes) and later `transferred` to consume the approval. Without approval, `detectTransferRestriction` returns code 46 and the transfer is rejected. The operator can revoke with `cancelTransferApproval`. To migrate to a different token, the compliance manager must first call `unbindToken` before binding the new one.
-
-#### Mint allowance
-
-This rule enforces a per-minter mint quota for one bound RuleEngine/token at a time. An operator sets the number of tokens each minter address is allowed to mint via `setMintAllowance(minter, amount)`. Every successful mint reduces the minter's remaining quota. The operator can adjust quotas at any time with `increaseMintAllowance` / `decreaseMintAllowance`. Regular transfers and burns are not restricted.
-
-Compatibility warning: `RuleMintAllowance` does not enforce quotas for a token that only calls the standard ERC-3643 3-arg compliance functions. It requires the CMTAT/RuleEngine spender-aware path so the minter address is passed as `spender`.
-
-For the same reason, it does not advertise the full ERC-3643 `ICompliance` interface through ERC-165; the 3-arg callbacks alone cannot enforce the mint quota.
-
-> ⚠️ **`canTransfer` / `detectTransferRestriction` are not authoritative for this rule** — they are hardcoded to "allowed" because the 3-arg signature has no minter identity, so they disagree with enforcement. Pre-flight a mint with the spender-aware view `canTransferFrom(minter, address(0), to, value)` (or `detectTransferRestrictionFrom`). See [RuleMintAllowance.md](./doc/technical/RuleMintAllowance.md#eligibility-views-which-one-is-authoritative).
-
-**Usage scenario**
-
-The compliance manager binds the rule to the RuleEngine with `bindToken(ruleEngine)`. Attempting to bind a second RuleEngine/token reverts until the current binding is removed with `unbindToken`. The operator assigns `setMintAllowance(alice, 100_000e18)`. Alice's mints deduct from her quota through `transferred(alice, address(0), recipient, amount)`; once exhausted, further mints revert with code 70 until the operator increases the quota.
-
-#### Conditional transfer (light, multi-token)
-
-This variant scopes approvals by token address. It hashes `(token, from, to, value)` and supports multiple bound tokens in a single rule instance. Each successful transfer consumes one approval in the calling token namespace. Mints (`from == address(0)`) and burns (`to == address(0)`) remain exempt.
-
-**Usage scenario**
-
-An operator calls `approveTransfer(tokenA, from, to, value)` for `tokenA`. A transfer on `tokenA` succeeds and consumes the approval. The same `(from, to, value)` transfer on `tokenB` is still rejected until separately approved with `approveTransfer(tokenB, from, to, value)`.
+| **Compliance** (`ICompliance`) | A `RuleEngine` holding rules | The token asks the rules whether a transfer may proceed |
+| **Identity registry** (`IIdentityRegistry`) | `IdentityRegistryWhitelist` | The token asks it whether a wallet is a verified investor |
-## Access Control
+
-The module `AccessControlModuleStandalone` implements RBAC access control by inheriting from OpenZeppelin's `AccessControlEnumerable`.
+_Diagram source: [`doc/schema/erc3643-slots.puml`](./doc/schema/erc3643-slots.puml)._
-Each rule implements its own access control by inheriting from `AccessControlModuleStandalone`. The default admin is the address passed as `admin` to the constructor at deployment.
+### Compliance: go through a RuleEngine
-#### `DEFAULT_ADMIN_ROLE` implicit role behaviour
+Use `RuleEngine`, not a bare rule. ERC-3643 drives mint and burn through `created` and `destroyed`, which the **validation rules do not implement** — they only expose `canTransfer` / `transferred`.
-`AccessControlModuleStandalone` overrides OpenZeppelin's `hasRole` so that any account holding `DEFAULT_ADMIN_ROLE` returns `true` for **every** role check. This is intentional: the OpenZeppelin `DEFAULT_ADMIN_ROLE` holder can already grant itself any role at any time, so treating it as implicitly holding all roles from the start removes unnecessary ceremony and makes access management easier in practice.
+`RuleEngine` implements the full `ICompliance` surface and forwards to the rules, so it is the supported path.
-Practical consequences integrators must be aware of:
+The operation rules do implement `created` / `destroyed`, but they are bound to a single token and are not a compliance contract on
+their own.
-- **`grantRole` to a default admin is a no-op.** `_grantRole` checks `!hasRole(role, account)` before writing storage; since the admin already returns `true` via the override, the storage write and the `RoleGranted` event are skipped. The admin will **not** appear in `getRoleMember` / `getRoleMemberCount` enumerations for non-default roles unless the role was explicitly granted before the admin was set.
-- **`revokeRole` / `renounceRole`** on a non-default role for a default admin are misleading. They emit `RoleRevoked` and clear the storage flag, but `hasRole` continues to return `true` because the account still holds `DEFAULT_ADMIN_ROLE`. The effective privilege is unchanged. To fully remove access, `DEFAULT_ADMIN_ROLE` itself must be revoked.
-- **Off-chain monitoring** should use `hasRole` queries, not role-membership events or enumeration, to determine the effective privileges of admin accounts.
+### Identity verification
-See also [docs.openzeppelin.com - AccessControl](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControl)
+ERC-3643 decides who may hold a token by asking an **identity registry** one question:
+`isVerified(wallet)` — is this a verified investor? A normal registry answers it by checking the wallet's
+on-chain identity contract (ONCHAINID) for the required claims.
-### Role Summary
+This library provides **both sides of that exchange**:
-| Role | Hash | Functions (by rule) |
+| Contract | What it is | Where it plugs in |
| --- | --- | --- |
-| `DEFAULT_ADMIN_ROLE` | `0x0000000000000000000000000000000000000000000000000000000000000000` | `grantRole`, `revokeRole`, `renounceRole` (all AccessControl rules); `setCheckSpender` (RuleWhitelist, RuleWhitelistWrapper); `setMaxTotalSupply`, `setTokenContract` (RuleMaxTotalSupply); `setIdentityRegistry`, `clearIdentityRegistry` (RuleIdentityRegistry) |
-| `ADDRESS_LIST_ADD_ROLE` | `0x1b03c849816e077359373cf0a8d6d8f741d643bc1e95273ffe11515f83bebf61` | `addAddress`, `addAddresses` (RuleWhitelist, RuleBlacklist) |
-| `ADDRESS_LIST_REMOVE_ROLE` | `0x1b94c92b564251ed6b49246d9a82eb7a486b6490f3b3a3bf3b28d2e99801f3ec` | `removeAddress`, `removeAddresses` (RuleWhitelist, RuleBlacklist) |
-| `SANCTIONLIST_ROLE` | `0x30842281ac34bdc7d568c7ab276f84ba6fc1a1de1ae858b0afd35e716fb0650d` | `setSanctionListOracle`, `clearSanctionListOracle` (RuleSanctionsList) |
-| `RULES_MANAGEMENT_ROLE` | `0xea5f4eb72290e50c32abd6c23e45de3d8300b3286e1cbc2e293114b92e034e5e` | `setRules`, `clearRules`, `addRule`, `removeRule` (RuleWhitelistWrapper) |
-| `OPERATOR_ROLE` | `0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | `approveTransfer`, `cancelTransferApproval` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken) |
-| `COMPLIANCE_MANAGER_ROLE` | `0xe5c50d0927e06141e032cb9a67e1d7092dc85c0b0825191f7e1cede600028568` | `bindToken`, `unbindToken` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken / RuleMintAllowance) |
-| `ALLOWANCE_OPERATOR_ROLE` | `0x86a2482724302deea267bc1ca14032806c318aeaf8d1e0d445a6fb7e7c997beb` | `setMintAllowance`, `increaseMintAllowance`, `decreaseMintAllowance` (RuleMintAllowance) |
-| `WHITELIST_ADD_ROLE` | `0x77c0b4c0975a0b0417d8ce295502737b95fee8923755fed0cce952907a1861ed` | `addWhitelistAddress`, `addWhitelistAddresses` (RuleERC2980) |
-| `WHITELIST_REMOVE_ROLE` | `0xf4d11a530c5b90f459c6ab1e335d3d77156b8ff3093308e4fca6d100ee87ade9` | `removeWhitelistAddress`, `removeWhitelistAddresses` (RuleERC2980) |
-| `FROZENLIST_ADD_ROLE` | `0xc52c49807a071974b9260f4b553ee09bd9fd85f687d8d4cc3232de7104ff7835` | `addFrozenlistAddress`, `addFrozenlistAddresses` (RuleERC2980) |
-| `FROZENLIST_REMOVE_ROLE` | `0x8be92b33a413d98540bfb0edc9129253db6d924f6c2e32c4b7809d237f7b2aaa` | `removeFrozenlistAddress`, `removeFrozenlistAddresses` (RuleERC2980) |
-
-### Ownable2Step variants
-
-For simpler ownership-based control, `Ownable2Step` variants (two-step ownership transfer) are available:
-
-- `RuleWhitelistOwnable2Step`
-- `RuleBlacklistOwnable2Step`
-- `RuleWhitelistWrapperOwnable2Step`
-- `RuleSanctionsListOwnable2Step`
-- `RuleIdentityRegistryOwnable2Step`
-- `RuleMaxTotalSupplyOwnable2Step`
-- `RuleERC2980Ownable2Step`
-- `RuleConditionalTransferLightOwnable2Step`
-- `RuleConditionalTransferLightMultiTokenOwnable2Step`
-- `RuleMintAllowanceOwnable2Step`
-
-`RuleConditionalTransferLightOwnable2Step` now grants approval and execution permissions exclusively to the owner.
-All `Ownable2Step` variants enforce access using OpenZeppelin's `onlyOwner` modifier.
-All `Ownable2Step` variants also advertise ERC-165 support for `IERC165` (`0x01ffc9a7`), ERC-173 ownership (`0x7f5828d0`), and Ownable2Step handover (`0x9ab669ef`).
-
-### Address List
-
-Common access control between the blacklist rule and whitelist rule.
-
-These roles are listed above in the Role Summary table.
-
-## Toolchains and Usage
-
-This repository is developed and tested with [Foundry](https://book.getfoundry.sh); a Hardhat config is also present for compilation and a small smoke test. Build settings (`foundry.toml` / `hardhat.config.js`): solc `v0.8.34`, EVM `Prague`, optimizer on (200 runs).
-
-### Main commands
-
-| Task | Command |
-| --- | --- |
-| Install / update submodules | `forge install` · `forge update` |
-| Build | `forge build` |
-| Contract sizes | `forge compile --sizes` |
-| Run all tests | `forge test` |
-| Run one test | `forge test --match-contract --match-test ` |
-| Gas report | `forge test --gas-report` |
-| Gas snapshot | `forge snapshot` (check only: `forge snapshot --check`) |
-| Coverage | `forge coverage` |
-| Coverage report ([`doc/coverage`](./doc/coverage/)) | `forge coverage --no-match-coverage "(script\|mocks\|test)" --report lcov && genhtml lcov.info --branch-coverage --prefix "$PWD/" --output-dir coverage` |
-| Invariant suite only | `forge test --match-path "test/invariant/*"` |
-| Format | `forge fmt` |
-| Deploy a script | `forge script script/.s.sol --rpc-url --account ` |
-
-### Invariant testing
-
-The two **stateful (operation) rules** — `RuleConditionalTransferLight` and `RuleMintAllowance` — are covered by a handler-driven `StdInvariant` suite in [`test/invariant/`](./test/invariant/), which fuzzes long randomly-ordered call sequences and re-checks four invariants after every step (8 192 calls each, `fail_on_revert = true`):
-
-| Invariant | Asserts |
-| --- | --- |
-| `invariant_approvalConservation` | `totalApproved − totalCancelled − totalExecuted == Σ approvalCounts` — approvals are never double-spent or lost |
-| `invariant_noApprovalExceedsTotalRecorded` | `Σ approvalCounts ≤ totalApproved` |
-| `invariant_allowanceMatchesGhost` | the on-chain mint quota exactly matches an independently-computed ghost mirror, after any interleaving |
-| `invariant_mintedNeverExceedsCredited` | `Σ minted ≤ Σ credited` |
-
-Both suites are **mutation-verified**: injecting an approval double-spend or an off-by-one quota deduction makes them fail. Validation rules are read-only and hold no per-transfer state, so they are covered by unit and fuzz tests instead.
-
-Full details — handler architecture, ghost variables, the negative controls, the coverage map against the threat-model invariants, and how to add a new one — are in **[doc/technical/INVARIANT_TESTS.md](./doc/technical/INVARIANT_TESTS.md)**.
-
-Deployment scripts: `script/DeployCMTATWithWhitelist.s.sol`, `script/DeployCMTATWithBlacklist.s.sol`, `script/DeployCMTATWithBlacklistAndSanctionsList.s.sol`.
-
-> **Deployment key security:** avoid passing `--private-key` on the command line (visible in shell history and to any process that can read `/proc`). Prefer hardware wallets (`--ledger`, `--trezor`) or encrypted keystores (`--account `). See [Foundry best practices](https://www.getfoundry.sh/best-practices).
-
-For the full toolchain guide — dependency versions, Hardhat commands, HTML coverage generation, the gas-benchmark workflow, and the generic Forge / Cast / Anvil / Chisel reference — see **[doc/FOUNDRY.md](./doc/FOUNDRY.md)** and the [Foundry book](https://book.getfoundry.sh/).
-
-## API
-
-### IRuleEngine
-
-All rules implement `IRuleEngine`. The behaviour of `transferred()` differs by rule type:
-
-- **Validation rules** implement `transferred()` as `view`: it re-runs the restriction check and reverts if the transfer would be blocked, but does not modify state.
-- **Operation rules** implement `transferred()` as a state-mutating function: it updates storage as part of the transfer (e.g. consuming an approval in `RuleConditionalTransferLight`).
-
-#### transferred
-
-```
-function transferred(address spender, address from, address to, uint256 value)
- external;
-```
-
-Called by a token or RuleEngine after a transfer. For validation rules, enforces the restriction check. For operation rules, mutates internal state.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ------------------------------------------------------------ |
-| `spender` | `address` | Address executing the transfer (owner, operator, or approved). |
-| `from` | `address` | Current token holder. |
-| `to` | `address` | Recipient address. |
-| `value` | `uint256` | Amount transferred. |
-
-------
-
-### IERC1404
-
-#### detectTransferRestriction
-
-```
-function detectTransferRestriction(address from, address to, uint256 value)
- external
- view
- returns (uint8);
-```
-
-Returns a restriction code describing why a transfer is blocked.
-
-##### Parameters
-
-| Name | Type | Description |
-| ------- | --------- | ------------------------- |
-| `from` | `address` | Sender address. |
-| `to` | `address` | Recipient address. |
-| `value` | `uint256` | Amount being transferred. |
-
-##### Returns
-
-| Name | Type | Description |
-| ----- | ------- | ---------------------------------------- |
-| `0` | `uint8` | Transfer allowed. |
-| other | `uint8` | Implementation-defined restriction code. |
-
-------
-
-#### messageForTransferRestriction
-
-```
-function messageForTransferRestriction(uint8 restrictionCode)
- external
- view
- returns (string memory);
-```
-
-Returns a human-readable message associated with a restriction code.
-
-##### Parameters
-
-| Name | Type | Description |
-| ----------------- | ------- | --------------------------------------------------------- |
-| `restrictionCode` | `uint8` | Restriction code returned by `detectTransferRestriction`. |
-
-##### Returns
-
-| Name | Type | Description |
-| --------- | -------- | ------------------------------------- |
-| `message` | `string` | Explanation for the restriction code. |
-
-------
-
-### IERC1404Extend
-
-#### REJECTED_CODE_BASE
-
-```
-enum REJECTED_CODE_BASE {
- TRANSFER_OK,
- TRANSFER_REJECTED_DEACTIVATED,
- TRANSFER_REJECTED_PAUSED,
- TRANSFER_REJECTED_FROM_FROZEN,
- TRANSFER_REJECTED_TO_FROZEN,
- TRANSFER_REJECTED_SPENDER_FROZEN,
- TRANSFER_REJECTED_FROM_INSUFFICIENT_ACTIVE_BALANCE
-}
-```
-
-Base transfer restriction codes used by ERC-1404 extensions.
-
-------
-
-#### detectTransferRestrictionFrom
-
-```
-function detectTransferRestrictionFrom(
- address spender,
- address from,
- address to,
- uint256 value
-)
- external
- view
- returns (uint8);
-```
-
-Restriction code for transfers performed by a spender (approved operator).
+| `RuleIdentityRegistry` | The side that **asks the question**: a transfer rule that calls `isVerified` on whatever registry the token uses, and blocks the transfer when the answer is no. | Added to a RuleEngine, like any other rule |
+| `IdentityRegistryWhitelist` | The side that **answers it**: a registry implementation that replies from a whitelist instead of reading ONCHAINIDs, so no identity contracts need deploying. | `token.setIdentityRegistry(...)`. It is **not** a rule, implements no `IRule`, and must never be added to a RuleEngine |
-##### Parameters
+
-| Name | Type | Description |
-| --------- | --------- | -------------------------------- |
-| `spender` | `address` | Address performing the transfer. |
-| `from` | `address` | Current token owner. |
-| `to` | `address` | Recipient address. |
-| `value` | `uint256` | Transfer amount. |
+_Diagram source: [`doc/schema/erc3643-identity-directions.puml`](./doc/schema/erc3643-identity-directions.puml)._
-##### Returns
+**Which one you need is decided by the token, not by preference.**
-| Name | Type | Description |
-| ------ | ------- | ---------------------------------------------------- |
-| `code` | `uint8` | 0 if transfer allowed, otherwise a restriction code. |
+- **On an ERC-3643 token**, plug `IdentityRegistryWhitelist` straight into the identity slot with
+ `setIdentityRegistry`. The token screens every transfer itself. Do **not** also add `RuleIdentityRegistry`
+ behind a RuleEngine: the token already consults the registry, so the rule would screen the same wallets a
+ second time for no added restriction.
+- **On a CMTAT token there is no identity slot at all** — `setIdentityRegistry` is an ERC-3643 concept, and
+ CMTAT has no equivalent. So `RuleIdentityRegistry` behind a RuleEngine is not one option among several, it
+ is the only way to apply identity-registry screening. It consults whichever registry you point it at:
+ your own ONCHAINID-backed one, or `IdentityRegistryWhitelist` if you have none.
-------
+That second case is why the two contracts compose at all, and it is pinned by
+`test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol`. They are wired by interface
+rather than inheritance: the rule holds an `IIdentityRegistryVerified` and only ever calls `isVerified`.
-### IERC7551Compliance
+### Matching the spec's semantics
-#### canTransferFrom
+`RuleReceiverWhitelist` reproduces ERC-3643 eligibility exactly: **only the receiver** is screened. The spec
+checks the receiver alone on purpose, so a de-listed holder can still exit a position; screening the sender
+would trap them. `RuleIdentityRegistry` follows the same default, with sender and spender checks available as
+explicit opt-ins.
-```
-function canTransferFrom(address spender, address from, address to, uint256 value)
- external
- view
- returns (bool);
-```
-
-Determines if a spender-initiated transfer is permitted.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | -------------------------- |
-| `spender` | `address` | Caller executing transfer. |
-| `from` | `address` | Token owner. |
-| `to` | `address` | Recipient. |
-| `value` | `uint256` | Amount. |
-
-##### Returns
-
-| Name | Type | Description |
-| --------- | ------ | ----------------------------- |
-| `allowed` | `bool` | `true` if transfer permitted. |
-
-------
-
-### IERC3643ComplianceRead
-
-#### canTransfer
-
-```
-function canTransfer(address from, address to, uint256 value)
- external
- view
- returns (bool isValid);
-```
-
-Returns whether a transfer is compliant.
-
-##### Parameters
-
-| Name | Type | Description |
-| ------- | --------- | ---------------- |
-| `from` | `address` | Sender. |
-| `to` | `address` | Receiver. |
-| `value` | `uint256` | Transfer amount. |
-
-##### Returns
-
-| Name | Type | Description |
-| --------- | ------ | -------------------- |
-| `isValid` | `bool` | `true` if compliant. |
-
-------
-
-### IERC3643IComplianceContract
-
-#### transferred
-
-```
-function transferred(address from, address to, uint256 value)
- external;
-```
-
-Hook invoked during an ERC-20 token transfer.
-
-##### Parameters
-
-| Name | Type | Description |
-| ------- | --------- | ------------------- |
-| `from` | `address` | Previous owner. |
-| `to` | `address` | New owner. |
-| `value` | `uint256` | Amount transferred. |
-
-### Address List Management
-
-> This API is common to whitelist and blacklist rules
-
-#### addAddresses
-
-```
-function addAddresses(address[] calldata targetAddresses)
- public
- onlyAddressListAdd
-```
-
-##### Description
-
-Adds multiple addresses to the internal address set.
-
-##### Details
-
-- Does **not** revert if one or more addresses are already listed.
-- Restricted by the rule's access control policy (role- or owner-based).
-- Emits `AddAddresses`. Skipped/added counts are not emitted to keep gas cost minimal.
-
-##### Parameters
-
-| Name | Type | Description |
-| ----------------- | ----------- | ------------------------------------------ |
-| `targetAddresses` | `address[]` | Array of addresses to be added to the set. |
-
-------
-
-#### removeAddresses
-
-```
-function removeAddresses(address[] calldata targetAddresses)
- public
- onlyAddressListRemove
-```
-
-##### Description
-
-Removes multiple addresses from the internal set.
-
-##### Details
-
-- Does **not** revert if an address is not currently listed.
-- Restricted by the rule's access control policy (role- or owner-based).
-- Emits `RemoveAddresses`. Skipped/removed counts are not emitted to keep gas cost minimal.
-
-##### Parameters
-
-| Name | Type | Description |
-| ----------------- | ----------- | --------------------------------- |
-| `targetAddresses` | `address[]` | Array of addresses to be removed. |
-
-------
-
-#### addAddress
-
-```
-function addAddress(address targetAddress)
- public
- onlyAddressListAdd
-```
-
-##### Description
-
-Adds a **single** address to the set.
-
-##### Details
-
-- **Reverts** if the address is already listed.
-- Restricted by the rule's access control policy (role- or owner-based).
-- Emits an `AddAddress` event.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------------- | --------- | --------------- |
-| `targetAddress` | `address` | Address to add. |
-
-------
-
-#### removeAddress
-
-```
-function removeAddress(address targetAddress)
- public
- onlyAddressListRemove
-```
-
-##### Description
-
-Removes a **single** address from the set.
-
-##### Details
-
-- **Reverts** if the address is not listed.
-- Restricted by the rule's access control policy (role- or owner-based).
-- Emits a `RemoveAddress` event.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------------- | --------- | ------------------ |
-| `targetAddress` | `address` | Address to remove. |
-
-------
-
-#### listedAddressCount
-
-```
-function listedAddressCount() public view returns (uint256 count)
-```
-
-##### Description
-
-Returns the total number of addresses currently listed in the internal set.
-
-##### Returns
-
-| Name | Type | Description |
-| ------- | --------- | --------------------------------- |
-| `count` | `uint256` | Total number of listed addresses. |
-
-------
-
-##### contains
-
-```
-function contains(address targetAddress)
- public
- view
- override(IIdentityRegistryContains)
- returns (bool isListed)
-```
-
-##### Description
-
-Checks whether a specific address is listed.
- Implements `IIdentityRegistryContains`.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------------- | --------- | ----------------- |
-| `targetAddress` | `address` | Address to check. |
-
-##### Returns
-
-| Name | Type | Description |
-| ---------- | ------ | --------------------------------------------------- |
-| `isListed` | `bool` | `true` if the address is listed, otherwise `false`. |
-
-------
-
-#### isAddressListed
-
-```
-function isAddressListed(address targetAddress)
- public
- view
- returns (bool isListed)
-```
-
-##### Description
-
-Returns whether a given address is included in the internal set.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------------- | --------- | ----------------- |
-| `targetAddress` | `address` | Address to check. |
-
-##### Returns
-
-| Name | Type | Description |
-| ---------- | ------ | --------------- |
-| `isListed` | `bool` | Listing status. |
-
-------
-
-#### areAddressesListed
-
-```
-function areAddressesListed(address[] memory targetAddresses)
- public
- view
- returns (bool[] memory results)
-```
-
-##### Description
-
-Checks the listing status of multiple addresses in a single call.
-
-##### Parameters
-
-| Name | Type | Description |
-| ----------------- | ----------- | ---------------------------- |
-| `targetAddresses` | `address[]` | Array of addresses to check. |
+Every rule and the registry implement `IERC3643Version`, so `version()` is queryable on-chain.
-##### Returns
+### Tested against a real ERC-3643 token
-| Name | Type | Description |
-| --------- | -------- | --------------------------------------------------- |
-| `results` | `bool[]` | Array of boolean listing results, aligned by index. |
+`test/ERC3643Real/` runs against the actual ERC-3643 `Token.sol` and `IdentityRegistry.sol`, not mocks: the
+RuleEngine integration, the identity rule against a real registry, and receiver-whitelist parity with the
+spec's eligibility. 31 tests, run with `FOUNDRY_PROFILE=erc3643 forge test`.
-#### Details
+## Quick start
-##### Null address
-
-It is possible to add the null address (0x0) to the address list. In a whitelist, this enables mint/burn flows (since `from`/`to` can be zero). In a blacklist, adding `0x0` blocks mint/burn.
-For `RuleWhitelist`, you can also pre-list `0x0` at deployment using the constructor parameter `allowMintBurn=true`.
-
-##### Duplicate address
-
-**addAddress**
-If the address already exists, the transaction is reverted to save gas.
-**addAddresses**
-If one of the addresses already exist, there is no change for this address. The transaction remains valid (no revert).
-
-##### NonExistent Address
-
-**removeAddress**
-If the address does not exist in the whitelist, the transaction is reverted to save gas.
-**removeAddresses**
-If the address does not exist in the whitelist, there is no change for this address. The transaction remains valid (no revert).
-
-
-
-### IERC7943NonFungibleCompliance
-
-Compliance interface for ERC-721 / ERC-1155–style non-fungible assets. This is implemented by validation rules only. `RuleConditionalTransferLight` and `RuleMaxTotalSupply` are ERC-20 only and do not implement this interface.
- For ERC-721, `amount` must always be `1`.
-
-------
-
-#### Functions
-
-| Name | Description |
-| --------------- | ------------------------------------------------------------ |
-| **canTransfer** | Verifies whether a transfer is permitted according to the token’s compliance rules. |
-
-------
-
-#### canTransfer
-
-```
-function canTransfer(
- address from,
- address to,
- uint256 tokenId,
- uint256 amount
-) external view returns (bool allowed)
-```
-
-##### Description
-
-Verifies whether a token transfer is permitted according to the rule-based compliance logic.
-
-##### Details
-
-- Must not modify state.
-- May enforce checks such as allowlists, blocklists, freezing, transfer limits, regulatory rules.
-- Must return `false` if the transfer is not permitted.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ----------------------------------------- |
-| `from` | `address` | Current token owner. |
-| `to` | `address` | Receiving address. |
-| `tokenId` | `uint256` | Token ID. |
-| `amount` | `uint256` | Transfer amount (always `1` for ERC-721). |
-
-##### Returns
-
-| Name | Type | Description |
-| --------- | ------ | ------------------------------------------------- |
-| `allowed` | `bool` | `true` if transfer is allowed; otherwise `false`. |
-
-------
-
-### IERC7943NonFungibleComplianceExtend
-
-Extended compliance interface for ERC-721 / ERC-1155 non-fungible assets. This is implemented by validation rules only. `RuleConditionalTransferLight` and `RuleMaxTotalSupply` are ERC-20 only and do not implement this interface.
- Adds restriction-code reporting, spender-aware checks, and a post-transfer hook.
-
-For ERC-721, `amount` / `value` must always be `1`.
-
-------
-
-#### Functions
-
-| Name | Description |
-| --------------------------------- | ------------------------------------------------------------ |
-| **detectTransferRestriction** | Returns a restriction code indicating why a transfer is blocked. |
-| **detectTransferRestrictionFrom** | Returns a restriction code for a spender-initiated transfer. |
-| **canTransferFrom** | Checks whether a spender-initiated transfer is allowed. |
-| **transferred** | Notifies the compliance engine that a transfer has occurred. |
-
-------
-
-#### detectTransferRestriction
-
-```
-function detectTransferRestriction(
- address from,
- address to,
- uint256 tokenId,
- uint256 amount
-) external view returns (uint8 code)
-```
-
-##### Description
-
-Returns a restriction code describing whether and why a transfer is blocked.
-
-##### Details
-
-- Must not modify state.
-- Must return `0` when the transfer is allowed.
-- Non-zero codes should follow ERC-1404 or similar standards.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ---------------------------------- |
-| `from` | `address` | Current token holder. |
-| `to` | `address` | Receiving address. |
-| `tokenId` | `uint256` | Token ID. |
-| `amount` | `uint256` | Transfer amount (`1` for ERC-721). |
-
-##### Returns
-
-| Name | Type | Description |
-| ------ | ------- | --------------------------------------------- |
-| `code` | `uint8` | `0` if allowed; otherwise a restriction code. |
-
-------
-
-#### detectTransferRestrictionFrom
-
-```
-function detectTransferRestrictionFrom(
- address spender,
- address from,
- address to,
- uint256 tokenId,
- uint256 value
-) external view returns (uint8 code)
-```
-
-##### Description
-
-Returns a restriction code for a transfer initiated by a spender (approved operator or owner).
-
-##### Details
-
-- Must not modify state.
-- Must return `0` when the transfer is permitted.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ---------------------------------- |
-| `spender` | `address` | Address performing the transfer. |
-| `from` | `address` | Current owner. |
-| `to` | `address` | Recipient address. |
-| `tokenId` | `uint256` | Token ID being checked. |
-| `value` | `uint256` | Transfer amount (`1` for ERC-721). |
-
-##### Returns
-
-| Name | Type | Description |
-| ------ | ------- | ------------------------------------------- |
-| `code` | `uint8` | `0` if allowed; otherwise restriction code. |
-
-------
-
-#### canTransferFrom
-
-```
-function canTransferFrom(
- address spender,
- address from,
- address to,
- uint256 tokenId,
- uint256 value
-) external view returns (bool allowed)
-```
-
-##### Description
-
-Checks whether a spender-initiated transfer is allowed under the compliance rules.
-
-##### Details
-
-- Must not modify state.
-- Should internally use `detectTransferRestrictionFrom`.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ---------------------------------------- |
-| `spender` | `address` | Address executing the transfer. |
-| `from` | `address` | Current owner. |
-| `to` | `address` | Recipient. |
-| `tokenId` | `uint256` | Token ID. |
-| `value` | `uint256` | Transfer amount (`1` for ERC-721 token). |
-
-##### Returns
-
-| Name | Type | Description |
-| --------- | ------ | ------------------------------ |
-| `allowed` | `bool` | `true` if transfer is allowed. |
-
-------
-
-#### transferred
-
-```
-function transferred(
- address spender,
- address from,
- address to,
- uint256 tokenId,
- uint256 value
-) external
-```
-
-##### Description
-
-Signals to the compliance engine that a transfer has successfully occurred.
-
-##### Details
-
-- May modify compliance state.
-- For stateful rules, should be called by the token contract or RuleEngine after a successful transfer.
-- Rules may enforce access control on callers depending on their policy.
-
-##### Parameters
-
-| Name | Type | Description |
-| --------- | --------- | ---------------------------------------- |
-| `spender` | `address` | Address executing the transfer. |
-| `from` | `address` | Previous owner. |
-| `to` | `address` | New owner. |
-| `tokenId` | `uint256` | Token transferred. |
-| `value` | `uint256` | Transfer amount (`1` for ERC-721 token). |
-
-### RuleSanctionsList
-
-Compliance rule enforcing sanctions-screening for token transfers.
- Integrates a sanctions-oracle (e.g., Chainalysis) to block transfers when the sender, recipient, or spender is sanctioned.
-
-------
-
-#### Constructor
-
-```solidity
-constructor(address admin, address forwarderIrrevocable, ISanctionsList sanctionContractOracle_)
-```
-
-Initializes access control, meta-transaction forwarder, and optionally the sanctions oracle.
-
-#### setSanctionListOracle
-
-```solidity
-function setSanctionListOracle(ISanctionsList sanctionContractOracle_)
- public
- virtual
- onlyRole(SANCTIONLIST_ROLE)
-```
-
-Set the sanctions-oracle contract used for transfer-restriction checks.
-
-##### Parameters
-
-| Name | Type | Description |
-| ------------------------- | ---------------- | ------------------------------------------------------------ |
-| `sanctionContractOracle_` | `ISanctionsList` | Address of the sanctions-oracle. Zero address is not allowed; use `clearSanctionListOracle`. |
-
-##### Description
-
-Updates the sanctions-oracle contract reference.
- This function may only be called by accounts granted the `SANCTIONLIST_ROLE`.
- Passing the zero address reverts; use `clearSanctionListOracle` to disable checks.
-
-##### Emits
-
-| Event | Description |
-| -------------------------------- | ----------------------------------------------------- |
-| `SetSanctionListOracle(address)` | Emitted when the sanctions-oracle address is updated. |
-
-### RuleMaxTotalSupply
-
-Compliance rule that caps total token supply; only mints (`from == address(0)`) are restricted.
-
-------
-
-#### Constructor
-
-```solidity
-constructor(address admin, address tokenContract_, uint256 maxTotalSupply_)
-```
-
-Initializes access control, the token contract, and the max supply.
-
-#### setMaxTotalSupply
-
-```solidity
-function setMaxTotalSupply(uint256 newMaxTotalSupply)
- public
- virtual
- onlyRole(DEFAULT_ADMIN_ROLE)
-```
-
-Updates the configured maximum supply.
-
-#### setTokenContract
-
-```solidity
-function setTokenContract(address tokenContract_)
- public
- virtual
- onlyRole(DEFAULT_ADMIN_ROLE)
-```
-
-Sets the token contract used to read `totalSupply()`.
-
-### RuleConditionalTransferLight
-
-Operation rule requiring explicit approval before a transfer executes.
-
-------
-
-#### bindToken
-
-```solidity
-function bindToken(address token)
- public
- onlyRole(COMPLIANCE_MANAGER_ROLE)
-```
-
-Binds a token so it may call `transferred()`.
-
-#### unbindToken
-
-```solidity
-function unbindToken(address token)
- public
- onlyRole(COMPLIANCE_MANAGER_ROLE)
-```
-
-Revokes the token binding.
-
-#### approveTransfer
-
-```solidity
-function approveTransfer(address from, address to, uint256 value)
- public
- onlyTransferApprover
-```
-
-Approves one transfer (consumed on execution).
-
-#### cancelTransferApproval
-
-```solidity
-function cancelTransferApproval(address from, address to, uint256 value)
- public
- onlyTransferApprover
+```bash
+forge build # compile
+forge test # run the suite
+FOUNDRY_PROFILE=erc3643 forge test # the real-ERC-3643-token suite
```
-Removes one approval for the transfer.
+Both commands are required: the vendored ERC-3643 `Token.sol` pins solc `0.8.30` exactly and cannot share a
+compilation unit with our `0.8.36`, so `test/ERC3643Real/**` builds under its own profile.
-#### approveAndTransferIfAllowed
+Deploying a token with rules attached:
-```solidity
-function approveAndTransferIfAllowed(address from, address to, uint256 value)
- public
- onlyTransferApprover
- returns (bool)
+```bash
+forge script script/DeployCMTATWithBlacklist.s.sol:DeployCMTATWithBlacklist --rpc-url --broadcast
```
-Approves then calls `SafeERC20.safeTransferFrom` on the bound token using this rule as spender.
-
-#### approvedCount
+Four scripts cover the common combinations. See
+[`DEPLOYMENT_SCRIPTS.md`](./doc/technical/guides/DEPLOYMENT_SCRIPTS.md) for configuration and limitations.
-```solidity
-function approvedCount(address from, address to, uint256 value)
- public
- view
- returns (uint256)
-```
+## Documentation
-Returns the number of approvals for the transfer hash.
+| Topic | Document |
+| --- | --- |
+| Full reference | [`doc/README.md`](./doc/README.md) |
+| Per-rule detail | [`doc/technical/`](./doc/technical/) |
+| Cross-rule semantics | [`RULE_SEMANTICS.md`](./doc/technical/guides/RULE_SEMANTICS.md) |
+| Deployment scripts | [`DEPLOYMENT_SCRIPTS.md`](./doc/technical/guides/DEPLOYMENT_SCRIPTS.md) |
+| Invariant tests | [`INVARIANT_TESTS.md`](./doc/technical/guides/INVARIANT_TESTS.md) |
+| Audits and static analysis | [`doc/security/audits/`](./doc/security/audits/) |
+| Release history | [`CHANGELOG.md`](./CHANGELOG.md) |
## Security
-### Manual Threat Model & Review (v0.4.0)
+No formal third-party audit has been carried out. The code has had automated static analysis and
+AI-assisted review, each triaged by the project team:
-The published report is [**`CLAUDE_AUDIT.md`**](./doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — findings, invariant verification, access-control verification, what was remediated, and the open improvement backlog. It is backed by the working deliverables at the repository root:
-
-| Document | Contents |
-|---|---|
-| [`CLAUDE_AUDIT.md`](./doc/security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) | **The audit report.** Findings, invariant + access-control verification, remediation record, open backlog |
-| [`THREAT_MODEL.md`](./THREAT_MODEL.md) | Trust model and actors, 30 catalogued threats with IDs, data-flow diagrams, 12 invariants, reachable privileged surface |
-| [`RESULT.md`](./RESULT.md) | Findings, invariant and access-control verification, and an explicit disposition for every threat ID |
-| [`TEST_IMPROVEMENT.md`](./TEST_IMPROVEMENT.md) | Test-gap analysis and the deferred test backlog |
-
-**Outcome: 0 Critical, 0 High, 0 Medium, 2 Low, 8 Informational.** Two hypotheses that would have been High were specifically probed and cleared: an ERC-2771 forwarder cannot impersonate a bound token (the operation rules deliberately do not inherit `ERC2771Context`), and the hand-rolled keccak preimage in `_transferHash` is injective.
-
-| ID | Severity | Summary |
-|---|---|---|
-| F-1 | Low | `RuleIdentityRegistry` screens the minter as `spender` on mint, unlike its three sibling allowlist rules, so issuance halts unless the minter is itself identity-verified. Fail-closed; no bypass |
-| F-4 | Low | `RuleConditionalTransferLightMultiToken` stores approvals under the caller-supplied `token` but consumes them under `msg.sender`. Behind a shared `RuleEngine` this strands token-keyed approvals and collapses per-token isolation |
-| F-2, F-3, F-5, F-7, F-8, F-9, F-10, F-14 | Info | Max-supply views panic on overflow; `approveAndTransferIfAllowed` is direct-binding-only; the wrapper does not interface-check child rules; `RuleMintAllowance.canTransfer` is not authoritative; multi-token `detectTransferRestriction` depends on `msg.sender`; `unbindToken` leaves stale state; documentation drift |
-
-Proofs live in [`test/ThreatModel/ThreatModelTests.t.sol`](./test/ThreatModel/ThreatModelTests.t.sol) (18 tests: 15 unit/integration, 3 fuzz).
+| Type | Tool | Latest run |
+| --- | --- | --- |
+| Static analysis | [Slither](https://github.com/crytic/slither) 0.11.5 | v0.5.0 |
+| Static analysis | [Aderyn](https://github.com/Cyfrin/aderyn) 0.6.5 | v0.5.0 |
+| AI-assisted review | Claude Code (Anthropic) | v0.5.0 |
+| AI-assisted review | Claude + custom security-audit skills | v0.4.0 |
+| AI-assisted review | [Wake Arena](https://getwake.io) (Ackee Blockchain Security) | v0.2.0 |
-### Automated Analysis
+Scope is the production contracts under `src/`; mocks, tests and vendored dependencies are excluded.
-See the consolidated [Audit & Security-Analysis Overview](./doc/security/audits/AUDIT_OVERVIEW.md) for the full index and triage. Latest tool outputs (including feedback documents) are in [`doc/security/audits/tools/v0.4.0/`](./doc/security/audits/tools/v0.4.0/).
+Every finding carries a written triage, including the ones dismissed as false positives or by-design. Nothing was outstanding as of `v0.5.0`.
-Commands used for `v0.4.0` (mocks excluded):
+Reports, triage and the threat model live in [`doc/security/audits/`](./doc/security/audits/), indexed by [`AUDIT_OVERVIEW.md`](./doc/security/audits/AUDIT_OVERVIEW.md).
-```bash
-slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
- > doc/security/audits/tools/v0.4.0/slither-report.md
-aderyn -x mocks --output doc/security/audits/tools/v0.4.0/aderyn-report.md
-```
+## Development
-#### Aderyn (v0.4.0)
-
-Static analysis with [Aderyn](https://github.com/Cyfrin/aderyn) 0.6.5, re-run **2026-07-14** after the security remediation. Full report and feedback in [`doc/security/audits/tools/v0.4.0/`](./doc/security/audits/tools/v0.4.0/). **No High/Medium issues; nothing to fix** — all 9 Low findings are by-design or false positives (see [feedback](./doc/security/audits/tools/v0.4.0/aderyn-report-feedback.md)). The run initially reported 10: an `Unused Import` (dead `RuleTransferValidation` import in the two `RuleSpenderWhitelist` deployment files) was a genuine cosmetic defect and has been **fixed**.
-
-| ID | Title | Instances | Verdict |
-|---|---|---|---|
-| L-1 | Centralization Risk | 68 | By design (regulated token issuer model) |
-| L-2 | Unspecific Solidity Pragma | 63 | By design (`^0.8.20` library; project pins solc 0.8.34) |
-| L-3 | Address State Variable Set Without Checks | 1 | False positive — zero-check enforced at public `setSanctionListOracle` |
-| L-4 | PUSH0 Opcode | 64 | By design — project targets Prague EVM |
-| L-5 | Modifier Invoked Only Once | 2 | By design — template method pattern |
-| L-6 | Empty Block | 61 | By design — `_authorize*()` hooks + required interface no-ops |
-| L-7 | Loop Contains `require`/`revert` | 3 | **By design — recommendation rejected.** Batch adds revert on `address(0)` on purpose: skipping it made the emitted event name the sentinel as a set member |
-| L-8 | Costly operations inside loop | 7 | By design — `EnumerableSet` requires one `SSTORE` per element |
-| L-9 | Unchecked Return | 13 | Mixed — majority false positives; constructor `_grantRole` intentional |
-| — | Unused Import | 0 | **Fixed** during this run (was 2) |
-
-#### Slither (v0.4.0)
-
-Static analysis with [Slither](https://github.com/crytic/slither) 0.11.5, re-run **2026-07-14** after the security remediation (tally unchanged from the previous run). Full report and feedback in [`doc/security/audits/tools/v0.4.0/`](./doc/security/audits/tools/v0.4.0/). **Nothing to fix** — the two High `arbitrary-send-erc20` hits are false positives (approval-gated, allowance-checked compliance flow); see [feedback](./doc/security/audits/tools/v0.4.0/slither-report-feedback.md).
-
-| Category | Severity | Instances | Verdict |
-|---|---|---|---|
-| arbitrary-send-erc20 | High | 2 | False positive — `from` guarded by `onlyTransferApprover`, recorded approval, allowance check, bound token (light + multi-token) |
-| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before internal helper |
-| calls-loop | Low | 16 | By design — wrapper must query each child rule; child rules are read-only |
-| assembly | Informational | 2 | By design — memory-safe hash in `_transferHash` (light + multi-token) |
-| naming-convention | Informational | 2 | By design — parameter names match ERC-2980 spec |
-| unused-state | Informational | 8 | False positive — `RuleNFTAdapter` constants used in base dispatch (per-contract analysis limitation) |
-
-#### Aderyn (v0.3.0)
-
-Static analysis was performed with [Aderyn](https://github.com/Cyfrin/aderyn). The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.3.0/`](./doc/security/audits/tools/v0.3.0/).
-
-| ID | Title | Instances | Verdict |
-|---|---|---|---|
-| L-1 | Centralization Risk | 46 | Acknowledged — by design (regulated token issuer model) |
-| L-2 | Unspecific Solidity Pragma | 54 | Acknowledged — intentional for a library |
-| L-3 | Address State Variable Set Without Checks | 1 | False positive — check enforced in public-facing function |
-| L-4 | PUSH0 Opcode | 54 | Acknowledged — project targets Prague EVM |
-| L-5 | Modifier Invoked Only Once | 2 | Acknowledged — template method pattern; inlining would break abstraction |
-| L-6 | Empty Block | 38 | Acknowledged — `_authorize*()` hooks use modifiers; intentional no-op implementations in required interface paths |
-| L-7 | Costly operations inside loop | 6 | Acknowledged — unavoidable (`EnumerableSet` requires one `SSTORE` per element) |
-| L-8 | Unchecked Return | 13 | Mixed — mostly false positives (`void` helpers or pre-checked single-item paths); constructor `_grantRole` intentionally ignored |
-
-No high-severity issues were reported.
-
-#### Slither (v0.3.0)
-
-Static analysis was performed with [Slither](https://github.com/crytic/slither). The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.3.0/`](./doc/security/audits/tools/v0.3.0/).
-
-| Category | Severity | Instances | Verdict |
-|---|---|---|---|
-| arbitrary-send-erc20 | High | 1 | False positive — `from` is guarded by `onlyTransferApprover`, ERC-20 allowance check, and a pre-recorded approval |
-| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before calling internal helper |
-| calls-loop | Low | 16 | Acknowledged — by design; wrapper must query each child rule; child rules are read-only |
-| assembly | Informational | 1 | Acknowledged — intentional gas optimisation in `_transferHash`; minimal and well-scoped |
-| naming-convention | Informational | 2 | Acknowledged — parameter names match ERC-2980 spec |
-| unindexed-event-address | Informational | 2 | Out of scope (both in `lib/RuleEngine`); `IAddressList` events previously fixed |
-| unused-state | Informational | 8 | False positive — `RuleNFTAdapter` constants used in base dispatch logic; Slither per-contract analysis limitation |
-
-#### Wake Arena (v0.2.0)
-
-AI-assisted static analysis was performed with [Wake Arena](https://getwake.io) by Ackee Blockchain Security. The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.2.0/`](./doc/security/audits/tools/v0.2.0/).
-
-*Ackee Blockchain Security, Wake Arena AI Report | CMTA: Rules, March 16, 2026 18:00 UTC.*
-
-| ID | Title | Severity | Confidence | Verdict |
-|---|---|---|---|---|
-| H-1 | ConditionalTransferLight approvals not scoped by token | High | High | Fixed — single-token binding enforced in `bindToken`; `RuleConditionalTransferLight_TokenAlreadyBound` error added |
-| M-1 | Incomplete `supportsInterface` breaks ERC-165 discovery | Medium | High | Fixed — pre-computed constants + `IERC7551Compliance` + full ERC-3643 `ICompliance` ID (`IERC3643ComplianceFull`, `0x3144991c`) added |
-| I-1 | RuleERC2980 docs omit frozen spender on `transferFrom` | Informational | High | Fixed (doc only) — README, `AGENTS.md`, and `CLAUDE.md` updated to document spender freeze path |
-| I-2 | `hasRole` override: admin implicitly passes all role checks | Informational | High | Fixed (doc only) — dedicated section added to README documenting intentional design and off-chain monitoring guidance |
+Parts of this project were written with the help of AI coding assistants, principally **Claude Code**
+(Anthropic) and **Codex** (OpenAI).
## Intellectual property
diff --git a/doc/FOUNDRY.md b/doc/FOUNDRY.md
index 3cb9751a..42d28d7b 100644
--- a/doc/FOUNDRY.md
+++ b/doc/FOUNDRY.md
@@ -8,13 +8,13 @@ Here are the settings for [Hardhat](https://hardhat.org) and [Foundry](https://g
- `hardhat.config.js`
- - Solidity [v0.8.34](https://docs.soliditylang.org/en/v0.8.34/)
+ - Solidity [v0.8.36](https://docs.soliditylang.org/en/v0.8.36/)
- EVM version: Prague (Pectra upgrade)
- Optimizer: true, 200 runs
- `foundry.toml`
- - Solidity [v0.8.34](https://docs.soliditylang.org/en/v0.8.34/)
+ - Solidity [v0.8.36](https://docs.soliditylang.org/en/v0.8.36/)
- EVM version: Prague (Pectra upgrade)
- Optimizer: true, 200 runs
@@ -24,13 +24,13 @@ Here are the settings for [Hardhat](https://hardhat.org) and [Foundry](https://g
- Forge std [v1.12.0](https://github.com/foundry-rs/forge-std/releases/tag/v1.12.0)
- - OpenZeppelin Contracts (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.6.1)
+ - OpenZeppelin Contracts (submodule) [v5.7.0](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.7.0)
- - OpenZeppelin Contracts Upgradeable (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.6.1)
+ - OpenZeppelin Contracts Upgradeable (submodule) [v5.7.0](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.7.0)
- - CMTAT [v3.3.0-rc1](https://github.com/CMTA/CMTAT/releases/tag/v3.3.0-rc1)
+ - CMTAT [v3.3.0-rc3](https://github.com/CMTA/CMTAT/releases/tag/v3.3.0-rc3)
- - RuleEngine [v3.0.0-rc4](https://github.com/CMTA/RuleEngine/releases/tag/v3.0.0-rc4)
+ - RuleEngine [v3.0.0-rc5](https://github.com/CMTA/RuleEngine/releases/tag/v3.0.0-rc5)
## Toolchain installation
diff --git a/doc/README.md b/doc/README.md
new file mode 100644
index 00000000..7109a8a5
--- /dev/null
+++ b/doc/README.md
@@ -0,0 +1,2070 @@
+# RuleEngine - Rules
+
+**Rules** is a collection of on-chain compliance and transfer-restriction rules designed for use with the [CMTA RuleEngine](https://github.com/CMTA/RuleEngine) and the [CMTAT token standard](https://github.com/CMTA/CMTAT).
+
+Each rule can be used **standalone**, directly plugged into a CMTAT token, **or** managed collectively via a RuleEngine.
+
+The **RuleEngine** is an external smart contract that applies transfer restrictions to security tokens such as **CMTAT** or [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643)-compatible tokens through a RuleEngine.
+Rules are modular validator contracts that the `RuleEngine` or `CMTAT` compatible token can call on every transfer to ensure regulatory and business-logic compliance.
+
+**Current package version:** `v0.5.0` (contracts report `version()` → `"0.5.0"`). Built against CMTAT `v3.3.0-rc3` and RuleEngine `v3.0.0-rc5`; see [Compatibility](#compatibility) for the supported range.
+
+> This project has not undergone an audit and is provided as-is without any warranties.
+
+## Table of Contents
+
+- [Schema](#schema)
+- [Overview](#overview)
+- [Compatibility](#compatibility)
+- [Specifications](#specifications)
+- [Architecture](#architecture)
+- [Types of Rules](#types-of-rules)
+- [Quick Start](#quick-start)
+- [Deployment Guide](#deployment-guide)
+- [Rules details](#rules-details)
+- [Access Control](#access-control)
+- [Toolchains and Usage](#toolchains-and-usage)
+- [API](#api)
+- [Security](#security)
+- [Development](#development)
+- [Intellectual property](#intellectual-property)
+
+## Schema
+
+A rule can be reached two ways, and the choice changes what `msg.sender` is inside it. Both are supported for
+CMTAT; only the first works for ERC-3643.
+
+### Topology A — through a RuleEngine
+
+
+
+_Diagram source: [`doc/schema/rule-via-ruleengine.puml`](./schema/rule-via-ruleengine.puml)._
+
+The holder calls `transfer` or `transferFrom` on the token **(1)**. The token reports the movement to its
+compliance contract **(2)** — the 3-argument `transferred(from, to, value)` for a plain transfer, the
+4-argument `transferred(spender, from, to, value)` when a spender is involved, which includes every mint. The
+`RuleEngine` relays that to each registered rule in turn **(3a–3c)** and returns the **first non-zero**
+restriction code, so rule order decides *which* code a rejection reports, not whether it is rejected.
+
+Use this whenever more than one rule applies. **ERC-3643 tokens require it**: they drive mint and burn through
+`created` and `destroyed`, which the validation rules do not implement — only `RuleEngine` implements the full
+`ICompliance` surface. Inside each rule `msg.sender` is the engine, not the token, which matters for the
+operation rules that key state on the caller.
+
+### Topology B — bound directly to the token
+
+
+
+_Diagram source: [`doc/schema/rule-direct.puml`](./schema/rule-direct.puml)._
+
+`token.setRuleEngine(rule)` puts a rule in the compliance slot with no engine in between, so the token calls
+the rule directly and `msg.sender` inside the rule is the **token itself**. It is one contract fewer and saves
+an engine hop on every transfer, which makes it the cheaper choice when a single validation rule is all you
+need. `RuleConditionalTransferLightMultiToken` requires it.
+
+**A bare rule cannot back an ERC-3643 token**, for the reason above: no `created` / `destroyed`. For ERC-3643,
+use Topology A.
+
+## Overview
+
+### Key Concepts
+
+- **Rules are controllers** that validate or modify token transfers.
+- They can be applied:
+ - Directly on **CMTAT** (no RuleEngine required), **or**
+ - Through the [**RuleEngine**](https://github.com/CMTA/RuleEngine) (for multi-rule orchestration).
+- Rules enforce conditions such as:
+ - Whitelisting / blacklisting
+ - Sanctions checks
+ - Multi-party operator-managed lists
+ - Conditional approvals
+ - Arbitrary compliance logic
+
+### Integration modes
+
+A rule can be consumed in three ways. All three call the same rule contract; they differ only in who calls it and how much of the compliance interface is required.
+
+| Mode | Caller | What the rule must implement | When to use |
+| --- | --- | --- | --- |
+| **Direct CMTAT rule** | A CMTAT token calls the rule directly (no RuleEngine) | `IRuleEngine` (`canTransfer` + `transferred`, including the spender-aware overload) | A single rule is enough; no multi-rule orchestration needed |
+| **RuleEngine-managed rule** | A `RuleEngine` aggregates one or more rules and calls each on every transfer | `IRule` (`IRuleEngine` + `canReturnTransferRestrictionCode`) | Several rules must be combined, ordered, or share restriction codes |
+| **ERC-3643 through RuleEngine** | An ERC-3643 token drives `created` / `destroyed` / transfer hooks on a RuleEngine, which forwards them to the rules | Rules as above; the **RuleEngine** implements the full ERC-3643 `ICompliance` | The token is ERC-3643 and needs full `ICompliance` — a standalone rule cannot back an ERC-3643 token directly |
+
+Interface details for each mode are documented under [Architecture](#architecture); full signatures live in the [API](#api) reference.
+
+## Compatibility
+
+| Component | Compatible Versions |
+| ---------------- | ---------------------------------------------------------- |
+| **Rules v0.5.0** | CMTAT ≥ v3.0.0 (tested against v3.3.0-rc3)
RuleEngine v3.0.0-rc5 |
+
+Spender-aware paths (e.g. `RuleMintAllowance`) rely on the 4-argument `canTransferFrom` / `transferred(spender, from, to, value)` callbacks, which require a CMTAT / RuleEngine that forwards the spender to the rule; this repository is validated against CMTAT `v3.3.0-rc3`. The other rules only use the 3-argument path and work across the full CMTAT ≥ v3.0.0 range.
+
+Each Rule implements the interface `IRuleEngine` defined in CMTAT.
+
+This interface declares the ERC-3643 functions `transferred` (read-write) and `canTransfer` (read-only) with several other functions related to [ERC-1404](https://github.com/ethereum/eips/issues/1404), [ERC-7551](https://ethereum-magicians.org/t/erc-7551-crypto-security-token-smart-contract-interface-ewpg-reworked/25477) and [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643).
+
+## Specifications
+
+### ERC-3643
+
+Each rule implements the following functions from the ERC-3643 `ICompliance` interface
+
+```solidity
+function canTransfer(address _from, address _to, uint256 _amount) external view returns (bool);
+function transferred(address _from, address _to, uint256 _amount) external;
+```
+
+However, contrary to the RuleEngine, the whole interface is not implemented: the **validation rules** do not declare `created` and `destroyed`, so a validation rule cannot back an ERC-3643 token on its own. (The operation rules — `RuleConditionalTransferLight`, `…MultiToken` and `RuleMintAllowance` — do implement both, but each is bound to a single token and is not a general compliance contract.)
+
+The alternative to use a Rule with an ERC-3643 token is through the RuleEngine, which implements the whole `ICompliance` interface.
+
+The diagram below shows the recommended integration: the ERC-3643 token drives transfer, mint (`created`) and burn (`destroyed`) compliance hooks on the RuleEngine, which forwards them to the rules. A rule used on its own only implements `canTransfer` + `transferred`, so it cannot back an ERC-3643 token directly.
+
+
+
+_Diagram source: doc/img/readme-erc3643-integration.puml._
+
+#### The identity registry slot
+
+An ERC-3643 token has a **second** pluggable slot besides compliance, and this library fills it too.
+
+
+
+_Diagram source: [`doc/schema/erc3643-slots.puml`](./schema/erc3643-slots.puml)._
+
+ERC-3643 decides who may hold a token by asking an identity registry one question, `isVerified(wallet)`. A
+standard registry answers it by reading the wallet's ONCHAINID for the required claims. This library supplies
+**both sides of that exchange**, and they face opposite directions:
+
+| Contract | What it is | Installed with |
+| --- | --- | --- |
+| [`RuleIdentityRegistry`](./technical/contracts/RuleIdentityRegistry.md) | The side that **asks**: a compliance rule that calls `isVerified` on whichever registry it is pointed at, and blocks the transfer when the answer is no | Added to a RuleEngine like any other rule |
+| [`IdentityRegistryWhitelist`](./technical/contracts/IdentityRegistryWhitelist.md) | The side that **answers**: a registry implementation (`IIdentityRegistryERC3643`) that replies from a whitelist instead of reading ONCHAINIDs | `token.setIdentityRegistry(...)` — **not** a rule, implements no `IRule`, never add it to a RuleEngine |
+
+**Which one you need is decided by the token, not by preference**, because only one of the two token standards
+has an identity slot at all:
+
+
+
+_Diagram source: [`doc/schema/erc3643-identity-directions.puml`](./schema/erc3643-identity-directions.puml)._
+
+- **ERC-3643 token — it has the slot.** Install `IdentityRegistryWhitelist` with `setIdentityRegistry` and the
+ token screens every transfer itself. Do **not** also register `RuleIdentityRegistry` behind a RuleEngine
+ here: the token already consults the registry, so the rule would screen the same wallets a second time
+ without adding any restriction.
+- **CMTAT token — there is no slot.** `setIdentityRegistry` is an ERC-3643 concept and CMTAT has no
+ equivalent, so `RuleIdentityRegistry` behind a RuleEngine is not one option among several: it is the only
+ way to apply identity-registry screening at all. It consults whichever registry you point it at, either your
+ own ONCHAINID-backed one or `IdentityRegistryWhitelist`.
+
+That second case is the reason the two contracts compose, and it is pinned end to end by
+[`test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol`](../test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol).
+They are wired by interface rather than inheritance: the rule holds an `IIdentityRegistryVerified` and only
+ever calls `isVerified`, which the registry implements as part of its ERC-3643 surface. Neither contract
+references the other.
+
+`IdentityRegistryWhitelist` keeps **no identity state**: `_identity` and `_country` are accepted for signature
+compatibility then discarded, and `investorCountry` is a constant `0`. The token itself must hold
+`IDENTITY_REGISTRAR_ROLE`, because `recoveryAddress` makes the token call `registerIdentity` and
+`deleteIdentity`.
+
+#### Matching the spec's screening semantics
+
+ERC-3643 requires that **only the receiver** be verified: `transferFrom` works the same way, `mint` and
+`forcedTransfer` check only the receiver, and `burn` bypasses eligibility entirely. That asymmetry is
+deliberate — screening the sender would trap a de-listed holder in their position.
+
+- [`RuleReceiverWhitelist`](./technical/contracts/RuleReceiverWhitelist.md) reproduces this exactly (code `81`).
+- [`RuleIdentityRegistry`](./technical/contracts/RuleIdentityRegistry.md) defaults to the same behaviour, with
+ `checkSender` / `checkSpender` available as explicit opt-ins, both `false` by default.
+
+Every rule and the registry implement `IERC3643Version`, so `version()` is queryable on-chain.
+
+#### Tested against a real ERC-3643 token
+
+[`test/ERC3643Real/`](../test/ERC3643Real/) exercises the integration against the actual ERC-3643 `Token.sol`
+and `IdentityRegistry.sol` rather than mocks:
+
+| Suite | Covers |
+| --- | --- |
+| `ERC3643RealTokenRuleEngine.t.sol` | A real ERC-3643 token driving rules through a RuleEngine |
+| `RuleIdentityRegistryWithRealERC3643Registry.t.sol` | The identity rule against a real `IdentityRegistry` |
+| `ERC3643ReceiverWhitelistParity.t.sol` | Receiver-whitelist parity with the spec's eligibility |
+
+31 tests, run with `FOUNDRY_PROFILE=erc3643 forge test`. They are **not** part of a plain `forge test`: the
+vendored `Token.sol` pins solc `0.8.30` exactly and cannot share a compilation unit with our `0.8.36`.
+
+### ERC-721/ERC-1155
+
+To improve compatibility with [ERC-721](https://eips.ethereum.org/EIPS/eip-721) and [ERC-1155](https://eips.ethereum.org/EIPS/eip-1155), most validation rules implement the interface `IERC7943NonFungibleComplianceExtend` which includes compliance functions with the `tokenId` argument.
+
+- Operation rules (such as `RuleConditionalTransferLight`) are ERC-20 only and do not expose the ERC-721/1155 interfaces.
+- The two supply-cap validation rules, `RuleMaxTotalSupply` and `RuleChainlinkPoR`, are ERC-20 only as well and do not expose the ERC-721/1155 interfaces. This is deliberate: they cap a fungible supply, so a `tokenId` dimension would be meaningless for them. Both also require the protected token to expose an aggregate `totalSupply()`, which plain ERC-721 does not (only `ERC721Enumerable` does) and which is not per-id for ERC-1155.
+
+The full per-rule overload matrix is in [`doc/technical/guides/RULE_SEMANTICS.md`](./technical/guides/RULE_SEMANTICS.md#3-overload-surface-erc-7943-tokenid--itransfercontext).
+
+While no rules currently apply restriction on the token id, the validation interfaces can be used to implement flexible restriction on ERC-721 or ERC-1155 tokens.
+
+```solidity
+// IERC7943NonFungibleCompliance interface
+// Read-only functions
+function canTransfer(address from, address to, uint256 tokenId, uint256 amount) external view returns (bool allowed)
+
+// IERC7943NonFungibleComplianceExtend interface
+// Read-only functions
+function detectTransferRestriction(address from, address to, uint256 tokenId, uint256 amount) external view returns (uint8 code);
+function detectTransferRestrictionFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external view returns (uint8 code);
+function canTransferFrom(address spender, address from, address to, uint256 tokenId, uint256 value) external returns (bool allowed);
+
+// State modifying functions (write)
+function transferred(address from, address to, uint256 tokenId, uint256 value) external;
+function transferred(address spender, address from, address to, uint256 tokenId, uint256 value) external;
+```
+
+The diagram below shows a non-fungible transfer flowing through the `tokenId`-aware compliance signatures.
+
+For validation rules a single `transferred(...)` call both validates and reverts — it internally runs `detectTransferRestrictionFrom` and requires `TRANSFER_OK` — so no separate pre-check is required in the transfer path; the read-only `detectTransferRestriction*` / `canTransfer*` overloads remain available for off-chain queries.
+
+The `RuleNFTAdapter` carries the `tokenId` argument but currently delegates to the address-based checks (`from` / `to` / `spender`), so no rule restricts on the token id yet.
+
+
+
+_Diagram source: doc/img/readme-erc721-erc1155-compliance.puml._
+
+
+
+## Architecture
+
+### Naming Conventions
+
+- `*Base` contracts contain core logic without an access-control policy.
+- `*InvariantStorage` contracts group constants, custom errors, and events.
+- `*Common` contracts provide shared helper logic across variants (legacy naming retained for compatibility).
+
+### Zero address in batch operations
+
+Every address-list rule (`RuleWhitelist`, `RuleReceiverWhitelist`, `RuleBlacklist`, `RuleSpenderWhitelist`, `RuleERC2980`) offers single and batch write functions. The two differ in exactly one way:
+
+| Input | Single (`addAddress`) | Batch (`addAddresses`) |
+| --- | --- | --- |
+| New address | added | added |
+| Already listed | **reverts** | skipped, counted |
+| Not listed, on removal | **reverts** | skipped, counted |
+| `address(0)` | **reverts** | **reverts — the whole batch** |
+
+So "batch operations are non-reverting" holds for duplicates and missing entries only. `address(0)` is rejected on **every** add path.
+
+That is deliberate. The batch convention skips duplicates because a duplicate is an idempotent no-op that the emitted event still describes truthfully. Silently dropping `address(0)` would not be truthful: `AddAddresses` echoes the input array, so the event would name the zero address as a set member when it is not one, re-polluting the exact off-chain view the guard exists to keep clean. The zero address is the ERC-20 mint/burn sentinel, never a participant — mint and burn permission is governed by the `allowMint` / `allowBurn` flags, never by list membership.
+
+**Operationally:** an operator submitting a batch that happens to contain a zero entry — a truncated CSV column, an unset field in a spreadsheet export — loses the entire batch to a revert rather than having 999 of 1000 addresses applied. Filter the input before submitting.
+
+### Directory Layout
+
+- `src/modules/`: reusable modules shared across rules (`AccessControlModuleStandalone`, `MetaTxModuleStandalone`, `VersionModule`).
+- `src/rules/interfaces/`: shared interfaces (`IAddressList`, `IIdentityRegistry`, `ISanctionsList`, `ITransferContext`, `AggregatorV3Interface`, `IDecimals`).
+- `src/rules/validation/abstract/`: shared base contracts and invariant storage.
+- `src/rules/validation/abstract/base/`: base contracts with core rule logic (no access control).
+- `src/rules/validation/abstract/core/`: shared adapters/validation helpers.
+- `src/rules/validation/abstract/invariant/`: invariant storage contracts (constants, errors, events).
+- `src/rules/validation/deployment/`: deployable validation rules (concrete contracts).
+- `src/rules/operation/`: read-write (operation) rules that modify state on transfer.
+- `src/registry/`: contracts that plug into a token's **identity registry** slot rather than its compliance slot (`IdentityRegistryWhitelist`).
+- `test/`: Foundry tests, one folder per rule.
+- `script/`: deployment scripts.
+
+### Rule - Code list
+
+> It is very important that each rule uses a unique code
+
+Here is the list of codes used by the different rules
+
+| Contract | Constant name | Value |
+| ---------------------------- | ------------------------------------ | ----- |
+| All | TRANSFER_OK (from CMTAT) | 0 |
+| RuleWhitelist | CODE_ADDRESS_FROM_NOT_WHITELISTED | 21 |
+| | CODE_ADDRESS_TO_NOT_WHITELISTED | 22 |
+| | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 23 |
+| | CODE_MINT_NOT_ALLOWED | 24 |
+| | CODE_BURN_NOT_ALLOWED | 25 |
+| | Reserved slot | 26-29 |
+| RuleSanctionsList | CODE_ADDRESS_FROM_IS_SANCTIONED | 30 |
+| | CODE_ADDRESS_TO_IS_SANCTIONED | 31 |
+| | CODE_ADDRESS_SPENDER_IS_SANCTIONED | 32 |
+| | Reserved slot | 33-35 |
+| RuleBlacklist | CODE_ADDRESS_FROM_IS_BLACKLISTED | 36 |
+| | CODE_ADDRESS_TO_IS_BLACKLISTED | 37 |
+| | CODE_ADDRESS_SPENDER_IS_BLACKLISTED | 38 |
+| | Reserved slot | 39-45 |
+| RuleConditionalTransferLight | CODE_TRANSFER_REQUEST_NOT_APPROVED | 46 |
+| | Reserved slot | 47-49 |
+| RuleMaxTotalSupply | CODE_MAX_TOTAL_SUPPLY_EXCEEDED | 50 |
+| | CODE_SUPPLY_ORACLE_UNAVAILABLE | 51 |
+| | Reserved slot | 52-54 |
+| RuleIdentityRegistry | CODE_ADDRESS_FROM_NOT_VERIFIED | 55 |
+| | CODE_ADDRESS_TO_NOT_VERIFIED | 56 |
+| | CODE_ADDRESS_SPENDER_NOT_VERIFIED | 57 |
+| | Reserved slot | 58-59 |
+| RuleERC2980 | CODE_ADDRESS_FROM_IS_FROZEN | 60 |
+| | CODE_ADDRESS_TO_IS_FROZEN | 61 |
+| | CODE_ADDRESS_SPENDER_IS_FROZEN | 62 |
+| | CODE_ADDRESS_TO_NOT_WHITELISTED | 63 |
+| | CODE_MINT_NOT_ALLOWED | 64 |
+| | CODE_BURN_NOT_ALLOWED | 65 |
+| RuleSpenderWhitelist | CODE_ADDRESS_SPENDER_NOT_WHITELISTED | 66 |
+| | Reserved slot | 67-69 |
+| RuleMintAllowance | CODE_MINTER_ALLOWANCE_EXCEEDED | 70 |
+| | Reserved slot | 71-74 |
+| RuleChainlinkPoR | CODE_RESERVES_EXCEEDED | 75 |
+| | CODE_RESERVES_FEED_STALE | 76 |
+| | CODE_RESERVES_ANSWER_INVALID | 77 |
+| | CODE_TOTAL_SUPPLY_UNAVAILABLE | 78 |
+| | CODE_RESERVES_FEED_UNAVAILABLE | 79 |
+| | Reserved slot | 80 |
+| RuleReceiverWhitelist | CODE_ADDRESS_RECEIVER_NOT_WHITELISTED | 81 |
+| RuleMaxBalance | CODE_MAX_BALANCE_EXCEEDED | 82 |
+| | CODE_BALANCE_UNAVAILABLE | 83 |
+| | Reserved slot | 82-84 |
+
+Note:
+
+- The CMTAT already uses the code 0-6 and the code 7-12 should be left free to allow further additions in the CMTAT.
+- If you decide to create your own rules, we encourage you to use code > 100 to leave free the other restriction codes for future rules added in this project.
+- Reserved slots are intentionally left unused for future rule expansion (maximum of 3 per rule).
+- New rule code blocks should start at codes ending in `1` or `6` (e.g., `21`, `26`), leaving the remaining codes in the previous block for that prior rule’s reserved slots.
+- Current allocations are legacy; new rules should follow the start-at-1-or-6 policy without changing existing codes.
+
+### Rules as Standalone Compliance Contracts
+
+Every rule implements the minimal interface expected by **CMTAT**, notably:
+
+```solidity
+function transferred(address from, address to, uint256 value)
+function transferred(address spender, address from, address to, uint256 value)
+```
+
+This makes rules directly pluggable into CMTAT without any intermediary RuleEngine.
+
+### Transfer Context Helper
+
+Rules also expose an optional unified entrypoint using `MultiTokenTransferContext` / `FungibleTransferContext` (see `ITransferContext`) to pass a single struct instead of multiple arguments.
+
+This is a helper API inspired by [TokenF](https://github.com/dl-tokenf/contracts) and does not replace the standard ERC-3643 / RuleEngine interfaces.
+
+Validation rules generally expose both the non-fungible and fungible variants. `RuleConditionalTransferLight` and `RuleConditionalTransferLightMultiToken` expose only the fungible variant, and `RuleMaxTotalSupply`, `RuleChainlinkPoR` and `RuleMintAllowance` expose neither — see the per-rule matrix in [`doc/technical/guides/RULE_SEMANTICS.md`](./technical/guides/RULE_SEMANTICS.md#3-overload-surface-erc-7943-tokenid--itransfercontext).
+
+Two struct variants are available:
+
+```solidity
+// For ERC-721 / ERC-1155 (includes tokenId)
+struct MultiTokenTransferContext {
+ bytes4 selector; // function selector of the original call
+ address sender; // operator/spender (address(0) for direct transfers)
+ address from; // token sender
+ address to; // token recipient
+ uint256 value; // amount transferred
+ uint256 tokenId; // token id (non-fungible)
+ bytes data; // Optional token-provided metadata for rules
+}
+
+// For ERC-20 (no tokenId)
+struct FungibleTransferContext {
+ bytes4 selector; // function selector of the original call
+ address sender; // operator/spender (address(0) for direct transfers)
+ address from; // token sender
+ address to; // token recipient
+ uint256 value; // amount transferred
+ bytes data; // Optional token-provided metadata for rules
+}
+```
+
+Both structs are passed to `transferred(MultiTokenTransferContext calldata ctx)` or `transferred(FungibleTransferContext calldata ctx)`. If `ctx.sender` is non-zero, the spender-aware path is used internally; otherwise the standard two-party path is used. The `data` field is reserved for optional token-provided metadata that rules can interpret.
+
+### Using Rules via RuleEngine
+
+When used through the RuleEngine, a rule must also implement:
+
+```solidity
+interface IRule is IRuleEngine {
+ function canReturnTransferRestrictionCode(uint8 restrictionCode)
+ external
+ view
+ returns (bool);
+}
+```
+
+The RuleEngine can then:
+
+- Aggregate multiple rules
+- Execute them sequentially on each transfer
+- Return restriction codes
+- Mutate rule state (operation rules)
+
+The same rule can also be plugged **directly** into a CMTAT token (see [Rules as Standalone Compliance Contracts](#rules-as-standalone-compliance-contracts) above): the direct-CMTAT path only requires `IRuleEngine`, while the RuleEngine-managed path additionally requires `IRule`. Full signatures for both interfaces are documented in the [API](#api) reference (`IRuleEngine`, `IERC1404Extend`, `IERC7551Compliance`, `IERC3643IComplianceContract`).
+
+## Types of Rules
+
+There are two categories of rules: validation rules (read-only) and operation rules (read-write).
+
+Separately, `src/registry/` holds [`IdentityRegistryWhitelist`](./technical/contracts/IdentityRegistryWhitelist.md) — **not a rule**. It plugs into an ERC-3643 token's *identity registry* slot (`token.setIdentityRegistry(...)`) and answers `isVerified` from a whitelist, so no ONCHAINID deployment is needed. It implements no `IRule` surface and must not be added to a `RuleEngine`. Note the direction: `RuleIdentityRegistry` *consults* an identity registry, whereas `IdentityRegistryWhitelist` *is* one.
+
+### Which rule should I use?
+
+| Need | Rule |
+| --- | --- |
+| Only approved holders can send/receive | `RuleWhitelist` |
+| Only approved holders can **receive** (ERC-3643 semantics; a de-listed holder can still exit) | `RuleReceiverWhitelist` |
+| Combine several whitelists (OR logic) | `RuleWhitelistWrapper` |
+| Restrict `transferFrom` operators (spenders) | `RuleSpenderWhitelist` |
+| Block known bad addresses | `RuleBlacklist` |
+| Block sanctioned addresses (Chainalysis oracle) | `RuleSanctionsList` |
+| Cap total token supply | `RuleMaxTotalSupply` |
+| Cap total supply at the reserves reported by a Chainlink Proof of Reserve feed | `RuleChainlinkPoR` |
+| Require identity-registry verification (ERC-3643) | `RuleIdentityRegistry` |
+| ERC-2980 Swiss compliance (whitelist + frozenlist) | `RuleERC2980` |
+| Require operator approval per transfer | `RuleConditionalTransferLight` |
+| Per-transfer approval across several **directly-bound** tokens (not behind a RuleEngine) | `RuleConditionalTransferLightMultiToken` |
+| Limit mint quota per minter | `RuleMintAllowance` |
+
+Each rule is also available in `Ownable2Step` and `AccessControl` variants; see [Choosing a Rule Variant](#choosing-a-rule-variant). Stateful rules have binding constraints — see the [Binding model](#binding-model) table.
+
+### How rules differ (semantics comparison)
+
+Rules do **not** all treat the spender, mint/burn, or an unset oracle the same way. The full side-by-side table — who each rule screens (`from` / `to` / spender on `transferFrom` / mint / burn), how it behaves when its oracle/registry is unset, whether it is stateful, and which pre-flight view is authoritative — is in **[RULE_SEMANTICS.md](./technical/guides/RULE_SEMANTICS.md)**. The differences most likely to surprise an integrator:
+
+- **Spender on mint.** `RuleWhitelist` / `RuleWhitelistWrapper` / `RuleSpenderWhitelist` **exempt** the minter; `RuleBlacklist` / `RuleSanctionsList` **screen** it (deny-list, by design); `RuleIdentityRegistry` also screens it, so the minter must itself be identity-verified; `RuleMintAllowance` **debits the minter's quota**.
+- **Unset oracle/registry.** `RuleSanctionsList` (oracle unset) and `RuleIdentityRegistry` (registry unset) **fail open** — all transfers pass. An empty `RuleWhitelistWrapper` **fails closed**. `RuleChainlinkPoR` cannot be left unset, and a broken or stale feed **fails closed for mints only** — transfers and burns still pass.
+- **Authoritative pre-flight view.** For `RuleMintAllowance`, `canTransfer` is not authoritative — use `canTransferFrom`. For `RuleConditionalTransferLightMultiToken`, `detectTransferRestriction` is `msg.sender`-dependent. Both are detailed below.
+
+### Views that are not authoritative
+
+Two rules answer the standard ERC-1404 / ERC-3643 read views with something other than the real answer. In both cases the reason is structural — the 3-argument signature cannot carry the information the rule needs — and in both cases a correct alternative exists. **The important part is that the misleading answer is not confined to the rule: it propagates through the `RuleEngine` to the token's own public views**, which is the API an integrator actually calls.
+
+| Rule | Not authoritative | Why | Use instead |
+| --- | --- | --- | --- |
+| `RuleMintAllowance` | `canTransfer` / `detectTransferRestriction` — hardcoded to allowed | The 3-arg signature carries no minter identity, and the quota is keyed on the minter | `canTransferFrom(minter, address(0), to, value)` or `detectTransferRestrictionFrom(...)` |
+| `RuleConditionalTransferLightMultiToken` | `canTransfer` / `detectTransferRestriction` — caller-dependent | The token key is derived from `msg.sender`, so any off-chain `eth_call` reads "not approved" even for an approved transfer | `canTransferForToken(token, from, to, value)` or `detectTransferRestrictionForToken(...)` |
+
+**Propagation.** `RuleEngineBase._detectTransferRestriction` aggregates by calling each rule's **3-argument** view and returning the first non-zero code, and CMTAT's `ValidationModuleERC1404` forwards the token's ERC-1404 views to the engine. So a `RuleMintAllowance` that returns `0` makes `ruleEngine.canTransfer(...)` **and** `cmtat.canTransfer(...)` report every mint as allowed, regardless of quota. The 4-argument chain (`detectTransferRestrictionFrom`) is unaffected at every level and carries the real answer.
+
+Neither is a defect to be fixed by returning a restriction code instead: ERC-1404 has no "cannot answer" value, so any non-zero code reads as *blocked*, and the token would then report every mint as forbidden — including the ones that will succeed. See [`doc/technical/contracts/RuleMintAllowance.md`](./technical/contracts/RuleMintAllowance.md#eligibility-views-which-one-is-authoritative) and [`doc/technical/contracts/RuleConditionalTransferLightMultiToken.md`](./technical/contracts/RuleConditionalTransferLightMultiToken.md).
+
+### Validation Rules (Read-Only)
+
+Validation rules only read blockchain state — they never modify it during a transfer. They implement `transferred()` as a `view` function: it re-runs the same restriction check and reverts if the transfer would be blocked, but writes nothing to storage.
+
+All validation rules implement `IRuleEngine` to be usable both standalone (plugged directly into CMTAT) and via the RuleEngine.
+
+Available validation rules: `RuleWhitelist`, `RuleReceiverWhitelist`, `RuleWhitelistWrapper`, `RuleSpenderWhitelist`, `RuleBlacklist`, `RuleSanctionsList`, `RuleMaxTotalSupply`, `RuleChainlinkPoR`, `RuleIdentityRegistry`, `RuleERC2980`.
+
+ A community made project, [RuleSelf](https://github.com/rya-sge/ruleself), which uses [Self](https://self.xyz), a zero-knowledge identity is also available but is not developed or maintained by CMTA.
+
+### Operation Rules (Read-Write)
+
+Operation rules modify blockchain state during transfer execution. Their `transferred()` function is state-mutating: it consumes or updates stored data as part of the transfer flow.
+
+Available operation rules: `RuleConditionalTransferLight`, `RuleConditionalTransferLightMultiToken`, `RuleMintAllowance`.
+
+A full-featured variant, `RuleConditionalTransfer`, is maintained as a separate experimental repository at [CMTA/RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer).
+
+## Quick Start
+
+```bash
+# 1. Clone the repository
+git clone
+cd Rules
+
+# 2. Install Foundry (if not already installed)
+# https://book.getfoundry.sh/getting-started/installation
+
+# 3. Install submodule dependencies
+forge install
+
+# 4. Compile
+forge build
+
+# 5. Run tests
+forge test
+```
+
+## Deployment Guide
+
+> ⚠️ **Before production deployment:** this project has [not undergone an audit](#ruleengine---rules). Review the unaudited status, configure roles with least privilege (grant only the roles each operator needs, and prefer the `Ownable2Step` variants for single-owner setups), and run an end-to-end transfer test on the target token setup.
+
+1. Deploy the rule contract(s) with the desired admin and optional module addresses.
+2. Configure the rule state and roles, including whitelist/blacklist entries and oracle or registry addresses.
+3. Add rules to the RuleEngine, or set the rule directly on the CMTAT token.
+4. Verify the transfer flow end-to-end with a small test transfer before enabling production flows.
+
+Full technical documentation for these scripts, including limitations, is in [`doc/technical/guides/DEPLOYMENT_SCRIPTS.md`](./technical/guides/DEPLOYMENT_SCRIPTS.md).
+
+Deployment scripts:
+- `script/DeployCMTATWithWhitelist.s.sol` — CMTAT + whitelist rule, bound directly to the token
+- `script/DeployCMTATWithBlacklist.s.sol` — CMTAT + blacklist rule, bound directly to the token
+- `script/DeployCMTATWithBlacklistAndSanctionsList.s.sol` — CMTAT + RuleEngine with blacklist and sanctions rules
+- `script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol` — the same plus a supply cap
+
+Each script deploys with the caller as both deployer and final admin, hands over every admin role, and
+renounces the deployer's, so no temporary rights outlive the transaction. Run one with:
+
+```bash
+forge script script/DeployCMTATWithBlacklist.s.sol:DeployCMTATWithBlacklist \
+ --rpc-url --broadcast
+```
+
+Omitting `--broadcast` simulates locally, which is what CI does for every script on each run.
+
+#### Script configuration
+
+Values are read from the environment, with the defaults below applied when a variable is unset, so the
+scripts run unconfigured. Shared settings live in `script/base/CMTATDeploymentBase.sol`.
+
+| Variable | Default | Applies to |
+| --- | --- | --- |
+| `CMTAT_NAME` | `CMTA Token` | all |
+| `CMTAT_SYMBOL` | `CMTAT` | all |
+| `CMTAT_DECIMALS` | `0` | all |
+| `CMTAT_TOKEN_ID` | `CMTAT_ISIN` | all |
+| `CMTAT_TERMS_NAME` / `CMTAT_TERMS_URI` / `CMTAT_TERMS_HASH` | example document | all |
+| `CMTAT_INFORMATION` | `CMTAT_info` | all |
+| `CMTAT_FORWARDER` | `address(0)` (meta-transactions off) | all |
+| `SANCTIONS_ORACLE` | `address(0)` | the two sanctions scripts |
+| `CMTAT_MAX_SUPPLY` | `1000000` | the max-total-supply script |
+
+> ⚠️ **An unset `SANCTIONS_ORACLE` fails open.** `RuleSanctionsList` is registered and reports no error,
+> and every transfer passes it until `setSanctionListOracle` is called. The oracle address is
+> chain-specific, so there is no safe default. Set it before the token goes live.
+
+### Choosing a Rule Variant
+
+Several rules are available in multiple access-control variants. Use the simplest one that fits your needs:
+
+- `AccessControl` variants: use when you need multi-operator roles or delegated administration.
+- `Ownable2Step` variants: use when you want a safer two-step ownership transfer.
+
+### Validation Rules (Read-Only)
+
+- Cannot modify blockchain state during transfers.
+- Used for simple eligibility checks.
+- Examples:
+ - Whitelist
+ - Whitelist Wrapper
+ - Spender Whitelist
+ - Blacklist
+ - Sanction list (Chainalysis)
+ - ERC-2980 (whitelist + frozenlist)
+
+### Operation Rules (Read-Write)
+
+- Can update state during transfer calls.
+- Example:
+ - Conditional Transfer (approval-based)
+
+## Rules details
+
+### Summary tab
+
+| Rule | Type
[read-only / read-write] | ERC-721 / ERC-1155 | ERC-3643 via RuleEngine / CMTAT path * | Security Audit planned in the roadmap | Description |
+| ------------------------------------------------------------ | ------------------------------------ | ------------------ | -------- | ------------------------------------- | ------------------------------------------------------------ |
+| RuleWhitelist | Read-only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a whitelist. |
+| RuleWhitelistWrapper | Read-Only | ✔ | ✔ | ✔ | This rule can be used to restrict transfers from/to only addresses inside a group of whitelist rules managed by different operators. |
+| RuleBlacklist | Read-Only | ✔ | ✔ | ✔ | This rule can be used to forbid transfer from/to addresses in the blacklist |
+| RuleSanctionsList | Read-Only | ✔ | ✔ | ✔ | The purpose of this contract is to use the oracle contract from [Chainalysis](https://go.chainalysis.com/chainalysis-oracle-docs.html) to forbid transfer from/to an address included in a sanctions designation (US, EU, or UN). |
+| RuleMaxTotalSupply | Read-Only | ✘ | ✔ | ✔ | This rule limits minting so that the total supply never exceeds a configured maximum. |
+| RuleChainlinkPoR | Read-Only | ✘ | ✔ | ✔ | This rule limits minting so that the total supply never exceeds the reserves reported by a [Chainlink Proof of Reserve](https://docs.chain.link/data-feeds/proof-of-reserve) data feed. |
+| RuleIdentityRegistry | Read-Only | ✔ | ✔ | ✔ | This rule checks the ERC-3643 Identity Registry for transfer participants when configured. |
+| RuleSpenderWhitelist | Read-Only | ✔ | ✔ | ✔ | This rule blocks `transferFrom` when the spender is not in the whitelist. Direct transfers are always allowed. |
+| RuleReceiverWhitelist | Read-Only | ✔ | ✔ | ✔ | This rule screens **only the receiver**, reproducing ERC-3643's eligibility rule (`transferFrom` works the same way; `mint` checks the receiver; `burn` is exempt). The sender and spender are never checked, so a de-listed holder can still exit. |
+| RuleERC2980 | Read-Only | ✔ | ✔ | ✔ | ERC-2980 Swiss Compliant rule combining a whitelist (recipient-only) and a frozenlist (blocks sender, recipient, and spender for `transferFrom`). Frozenlist takes priority over whitelist. |
+| RuleConditionalTransferLight | Read-Write | ✘ | ✔ | ✔ | This rule requires that transfers have to be approved by an operator before being executed. Each approval is consumed once and the same transfer can be approved multiple times. |
+| RuleConditionalTransferLightMultiToken | Read-Write | ✘ | ✔ | ✔ | Multi-token variant of ConditionalTransferLight. Approvals are token-scoped with key `(token, from, to, value)` so one token cannot consume another token's approvals. |
+| RuleMintAllowance | Read-Write | ✘ | Partial † | ✔ | Enforces a per-minter mint quota managed by an operator; each mint reduces the minter's allowance. Regular transfers and burns are not restricted. |
+| [RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer) (external) | Read-Write | ✘ | ✔ | ✘
(experimental rule) | Full-featured approval-based transfer rule implementing Swiss law *Vinkulierung*. Supports automatic approval after three months, automatic transfer execution, and a conditional whitelist for address pairs that bypass approval. Maintained in a separate repository. |
+| [RuleSelf](https://github.com/rya-sge/ruleself) (community) | — | ✘ | — | ✘
(community project) | Use [Self](https://self.xyz), a zero-knowledge identity solution to determine which is allowed to interact with the token.
Community-maintained rule project. Not developed or maintained by CMTA. |
+
+All rules implement the CMTAT rule interfaces needed by their supported transfer paths. Some operation rules require the spender-aware callback, as documented in their rule-specific notes.
+
+* A checkmark in this column means the rule enforces compliance for ERC-3643 tokens **through a RuleEngine or the CMTAT transfer path** — it does **not** mean the rule is itself a full ERC-3643 `ICompliance` contract. A standalone rule implements only `canTransfer` + `transferred`, so it cannot back an ERC-3643 token directly; use it through a RuleEngine, which implements the full `ICompliance` interface (see [Integration modes](#integration-modes)).
+
+† `RuleMintAllowance` is **Partial**: it does not advertise the full ERC-3643 `ICompliance` interface via ERC-165 because its per-minter mint quota requires the spender-aware mint callback to identify the minter, which the 3-argument ERC-3643 mint callback cannot provide.
+
+### Technical documentation
+
+Technical documentation lives in [`doc/technical/`](./technical/), split in two:
+
+| Directory | Contents |
+| --- | --- |
+| [`technical/contracts/`](./technical/contracts/) | One page per deployable contract: what it enforces, its restriction codes, roles, methods and caveats |
+| [`technical/guides/`](./technical/guides/) | Cross-cutting material that spans several contracts: [`RULE_SEMANTICS.md`](./technical/guides/RULE_SEMANTICS.md) (how the rules differ from one another), [`INVARIANT_TESTS.md`](./technical/guides/INVARIANT_TESTS.md) (the stateful invariant suite), [`DEPLOYMENT_SCRIPTS.md`](./technical/guides/DEPLOYMENT_SCRIPTS.md) (the `script/` deployment scripts) |
+
+Per-contract pages:
+
+| Rule | Document |
+| ---- | -------- |
+| RuleWhitelist | [RuleWhitelist.md](./technical/contracts/RuleWhitelist.md) |
+| RuleWhitelistWrapper | [RuleWhitelistWrapper.md](./technical/contracts/RuleWhitelistWrapper.md) |
+| RuleBlacklist | [RuleBlacklist.md](./technical/contracts/RuleBlacklist.md) |
+| RuleSanctionsList | [RuleSanctionsList.md](./technical/contracts/RuleSanctionsList.md) |
+| RuleMaxTotalSupply | [RuleMaxTotalSupply.md](./technical/contracts/RuleMaxTotalSupply.md) |
+| RuleMaxBalance | [RuleMaxBalance.md](./technical/contracts/RuleMaxBalance.md) |
+| RuleChainlinkPoR | [RuleChainlinkPoR.md](./technical/contracts/RuleChainlinkPoR.md) |
+| IdentityRegistryWhitelist | [IdentityRegistryWhitelist.md](./technical/contracts/IdentityRegistryWhitelist.md) |
+| RuleIdentityRegistry | [RuleIdentityRegistry.md](./technical/contracts/RuleIdentityRegistry.md) |
+| RuleSpenderWhitelist | [RuleSpenderWhitelist.md](./technical/contracts/RuleSpenderWhitelist.md) |
+| RuleReceiverWhitelist | [RuleReceiverWhitelist.md](./technical/contracts/RuleReceiverWhitelist.md) |
+| RuleERC2980 | [RuleERC2980.md](./technical/contracts/RuleERC2980.md) |
+| RuleConditionalTransferLight | [RuleConditionalTransferLight.md](./technical/contracts/RuleConditionalTransferLight.md) |
+| RuleConditionalTransferLightMultiToken | [RuleConditionalTransferLightMultiToken.md](./technical/contracts/RuleConditionalTransferLightMultiToken.md) |
+| RuleMintAllowance | [RuleMintAllowance.md](./technical/contracts/RuleMintAllowance.md) |
+| RuleConditionalTransfer | [RuleConditionalTransfer.md](./technical/contracts/RuleConditionalTransfer.md) — **maintained in a separate repository**, kept here for reference |
+| Deployment scripts | [DEPLOYMENT_SCRIPTS.md](./technical/guides/DEPLOYMENT_SCRIPTS.md) |
+
+### Operational Notes
+
+#### Binding model
+
+Stateful (operation) rules restrict which caller may consume their state via `transferred()`, so the target must be explicitly bound with `bindToken`. The binding model differs per rule:
+
+| Rule | Binding model | Notes |
+| --- | --- | --- |
+| `RuleConditionalTransferLight` | Single token **+ optional RuleEngine** | Two independent bindings: `bindToken(token)` sets the ERC-20 this rule acts on, `bindRuleEngine(engine)` authorises the engine to call `transferred`. `transferred` accepts either. Behind a RuleEngine, bind **both** — then `approveAndTransferIfAllowed` works too. Rebind only after `unbindToken` / `unbindRuleEngine`. See [Binding: token vs RuleEngine](./technical/contracts/RuleConditionalTransferLight.md#binding-token-vs-ruleengine) |
+| `RuleConditionalTransferLightMultiToken` | **Multiple direct tokens only** | Approvals keyed by `(token, from, to, value)` but *consumed* under `msg.sender`. ⚠️ **Do not add this rule to a `RuleEngine`** — bind each token directly (`CMTAT.setRuleEngine(rule)`). Behind an engine the rule either reverts or silently loses all per-token isolation; see [Deployment topology](./technical/contracts/RuleConditionalTransferLightMultiToken.md#deployment-topology--why-a-ruleengine-does-not-work) |
+| `RuleMintAllowance` | Single RuleEngine/token | Bind the RuleEngine address in a CMTAT + RuleEngine setup; rebind only after `unbindToken`. Requires the spender-aware mint callback |
+
+Validation (read-only) rules have no binding requirement: they hold no per-transfer state and can be shared across tokens and RuleEngines freely.
+
+#### RuleIdentityRegistry
+
+- `RuleIdentityRegistry`: allows burns (`to == address(0)`) even if the sender is not verified. This matters only if the token allows self-burn.
+- `RuleIdentityRegistry`: can be disabled with `clearIdentityRegistry()`, which allows all transfers to pass this rule.
+- `RuleIdentityRegistry`: constructor accepts `address(0)` to start in a disabled state.
+
+#### RuleSanctionsList
+
+- `RuleSanctionsList`: rejects zero address in `setSanctionListOracle`. Use `clearSanctionListOracle()` to disable checks.
+- `RuleSanctionsList`: constructor accepts `address(0)` to start in a disabled state.
+
+#### RuleMaxTotalSupply
+
+- `RuleMaxTotalSupply`: trusts the configured `tokenContract` to report an **accurate** `totalSupply()`, but not to stay callable — a reverting or codeless token yields code 51 instead of breaking the MUST-NOT-revert views. Configuration rejects a non-contract token and probes that `totalSupply()` is callable.
+- `RuleMaxTotalSupply`: does not allow clearing the token contract; disable the rule by removing it from the RuleEngine or token.
+
+#### RuleChainlinkPoR
+
+- `RuleChainlinkPoR`: trusts the configured `tokenContract` to report an **accurate** `totalSupply()`, but not to stay callable — a reverting or codeless token yields code 78 instead of breaking the MUST-NOT-revert views. Configuration rejects a non-contract token and probes that `totalSupply()` is callable.
+- `RuleChainlinkPoR`: the feed's `decimals()` is read **live on every check**, never cached. Caching would save ~2,900 gas per mint but a feed that changed its decimals would then be mis-scaled by `10 ** delta` with no on-chain signal, overstating reserves and authorising unbacked minting. See [the rationale](./technical/contracts/RuleChainlinkPoR.md#why-the-decimals-are-read-live-and-what-it-costs).
+- `RuleChainlinkPoR`: feed problems block **mints only**, reported by kind — `79` when no usable response could be obtained (`decimals()` / `latestRoundData()` reverted, or decimals above the bound), `77` when a round was returned but is unusable (negative reserve, incomplete round), `76` when the answer is stale. Transfers and burns short-circuit before any feed access, so a lapsed feed never traps holders and costs them nothing.
+- `RuleChainlinkPoR`: **one instance protects exactly one token, and nothing on-chain enforces that.** The rule always reads `totalSupply()` from the configured `tokenContract`, never from whichever token triggered the check — it cannot learn that identity, since behind a RuleEngine the caller is the engine and the callback carries no token address. Adding one instance to two RuleEngines therefore evaluates *both* tokens against the first token's supply and feed, which can silently over-mint the second one or freeze it, with no revert or event to signal it. Deploy one instance per protected token. `RuleMaxTotalSupply` has the same exposure. See [One instance per protected token](./technical/contracts/RuleChainlinkPoR.md#one-instance-per-protected-token).
+- `RuleChainlinkPoR`: the feed cannot be cleared and cannot be the zero address; disable the rule by removing it from the RuleEngine or token.
+- `RuleChainlinkPoR`: set `maxStalenessSeconds` from the feed's **heartbeat**; `0` disables the staleness check entirely.
+- `RuleChainlinkPoR`: the mint ceiling equals the reported reserves exactly — there is no margin parameter. Compose with `RuleMaxTotalSupply` if you also want a static cap, or report conservative reserves upstream for a cushion.
+- `RuleChainlinkPoR`: for a token that does not expose `decimals()`, the configured value is trusted as-is — a wrong value allows over-minting or blocks valid mints.
+
+#### RuleWhitelistWrapper
+
+- `RuleWhitelistWrapper`: requires child rules that implement `IAddressList`. A wrapper with zero rules rejects all transfers (fail-closed).
+- **Scan cost is paid on every transfer, by the transferring user.** The wrapper makes one external `STATICCALL` per child rule — **~8.8k gas each** — and the scan runs during transfer *execution*, not only in views. At the default cap of 10 children the worst case is ~90k gas per transfer (~121k with `checkSpender = true`).
+- **Two amplifiers:** a transfer that is going to be *rejected* never resolves its target addresses, so it never early-exits and always scans **all** children — the failing path is the most expensive one. And `checkSpender = true` adds a third address that must also be found, lowering the early-exit rate.
+- **Operator responsibility:** keep the child list at or below the default `maxRules = 10`, and order children by expected hit rate so the early exit fires sooner. The scan is linear (~8.8k gas/child, measured flat up to 200 children), so `setMaxRules` accepts any non-zero value and raising the cap to 100 makes every transfer cost ~884k gas. That is a permanent tax on holders rather than a broken token — transfers still fit in a block until ~3,400 children — but it cannot be undone for transfers already paid. Full cost model and guidance: [RuleWhitelistWrapper.md](./technical/contracts/RuleWhitelistWrapper.md#gas-cost-of-the-child-rule-scan).
+
+#### RuleSpenderWhitelist
+
+- `RuleSpenderWhitelist`: only checks the spender in `transferFrom`; direct transfers always pass this rule.
+
+#### RuleReceiverWhitelist
+
+- `RuleReceiverWhitelist`: screens **only the receiver**, reproducing ERC-3643's eligibility rule. The sender and the spender are never checked — deliberately, so a de-listed holder can still exit their position rather than being trapped. Use `RuleWhitelist` if you want both parties screened.
+- `RuleReceiverWhitelist`: **burn is always allowed** (`to == address(0)` is exempt, since the zero address can never be listed), and mint is screened on the receiver like any other transfer — there is no `allowMint`/`allowBurn` flag. Compose with `RuleMaxTotalSupply` or `RuleChainlinkPoR` to cap issuance.
+
+#### RuleERC2980
+
+- `RuleERC2980`: frozenlist takes priority over whitelist; an address that is both whitelisted and frozen is rejected.
+- `RuleERC2980`: a frozen address acting as `transferFrom` spender is also blocked (code 62), even if `from` and `to` are not frozen.
+- `RuleERC2980`: sender (`from`) does not need to be whitelisted; only recipient (`to`) must be whitelisted.
+
+#### RuleConditionalTransferLight
+
+- `RuleConditionalTransferLight`: approvals are keyed by `(from, to, value)` and are not nonce-based.
+- `RuleConditionalTransferLight`: `approveAndTransferIfAllowed` approves and immediately executes `transferFrom` when this rule has allowance; it assumes token callback to `transferred()`.
+- `RuleConditionalTransferLight`: `transferred()` is restricted to the single token bound via `bindToken`; second bind reverts with `RuleConditionalTransferLight_TokenAlreadyBound` until `unbindToken`.
+- `RuleConditionalTransferLight`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`.
+
+#### RuleConditionalTransferLightMultiToken
+
+- `RuleConditionalTransferLightMultiToken`: approvals are keyed by `(token, from, to, value)` and are not nonce-based.
+- `RuleConditionalTransferLightMultiToken`: operator functions are token-scoped (`approveTransfer(token, ...)`, `cancelTransferApproval(token, ...)`, `approvedCount(token, ...)`, `approveAndTransferIfAllowed(token, ...)`).
+- `RuleConditionalTransferLightMultiToken`: execution is restricted to bound tokens; only the calling bound token can consume approvals for its own key space.
+- `RuleConditionalTransferLightMultiToken`: mints (`from == address(0)`) and burns (`to == address(0)`) are exempt from approval checks; `created` and `destroyed` delegate to `_transferred`.
+- `RuleConditionalTransferLightMultiToken`: with a shared `RuleEngine`, the caller seen by the rule is the engine address (not the underlying token). In that topology, token-scoped approvals are not visible unless approvals are keyed to the engine address, which is not per-token scoping.
+- **Warning**: `RuleConditionalTransferLightMultiToken` supports several tokens when integrated directly with each token contract. It must not be used for per-token approval isolation through a shared `RuleEngine`.
+
+#### General notes
+
+- All validation rules: read-only rules still implement `transferred()` for ERC-3643 and RuleEngine compatibility, but do not change state.
+- All AccessControl variants: use `onlyRole(ROLE)` in `_authorize*()` and mark internal helpers `virtual`.
+- All AccessControl variants: use `AccessControlEnumerable`, so role members can be enumerated with `getRoleMember` / `getRoleMemberCount`; default admin is treated as having all roles via `hasRole`, but may not appear in role member lists unless explicitly granted.
+- All meta-tx-enabled rules: `forwarderIrrevocable` is accepted as-is (including `address(0)`) and is not validated against ERC-165 because some forwarders do not implement it.
+- All rules: implement `IERC3643Version` via `VersionModule` and expose `version()` returning `"0.5.0"`.
+
+### Read-only (validation) rule
+
+Currently, there are eight validation rules: whitelist, whitelist wrapper, spender whitelist, blacklist, sanctions list, max total supply, identity registry, and ERC-2980.
+
+#### Whitelist
+
+Only whitelisted addresses may hold or receive tokens.
+ Transfers are rejected if:
+
+- `from` is not whitelisted
+- `to` is not whitelisted
+
+The rule is read-only: it only checks stored state.
+- Constructor parameter `allowMintBurn` sets **both** `allowMint` and `allowBurn` — the common case. Use `setAllowMint(bool)` / `setAllowBurn(bool)` afterwards for independent control (e.g. permanently close issuance while keeping redemptions open).
+- Mint/burn permission is an **explicit flag**, never list membership of `address(0)`. The zero address can never enter the list (`addAddress(address(0))` reverts), so `isVerified(address(0))` / `contains(address(0))` stay `false`, as ERC-3643 requires.
+- The flag gates the **operation only**: a permitted mint still requires a whitelisted *recipient*; a permitted burn still requires a whitelisted *sender*.
+- Blocked mint/burn return dedicated codes `24` / `25` (not the misleading "sender not whitelisted").
+
+**Example**
+
+During a transfer, this rule, called by the RuleEngine, will check if the address concerned is in the list, applying a read operation on the blockchain.
+
+**Usage scenario**
+
+An operator configures CMTAT to use `RuleWhitelist`. The issuer tries to mint to Alice via `mint`/`transfer` and the token calls `detectTransferRestriction`/`transferred`; Alice is not listed so the call reverts. The operator calls `addAddress(Alice)`. The issuer retries the mint and it succeeds.
+
+
+
+#### Spender whitelist
+
+This rule only checks `transferFrom` spender authorization:
+
+- Direct transfers (`transfer`) are always allowed by this rule.
+- `transferFrom` is rejected when `spender` is not listed.
+- Restriction code: `66` (`CODE_ADDRESS_SPENDER_NOT_WHITELISTED`).
+
+**Usage scenario**
+
+The operator deploys `RuleSpenderWhitelist` and sets it in the token or `RuleEngine`. Alice calls `transfer` to Bob and it passes this rule. Bob then tries `transferFrom(Alice, Bob, amount)` and it is rejected until the operator calls `addAddress(Bob)` (or whichever spender account should be authorized).
+
+
+
+#### Whitelist wrapper
+
+Allows independent whitelist groups managed by different operators.
+
+- Each operator manages a dedicated whitelist.
+- A transfer is allowed only if both addresses belong to *at least one* operator-managed list.
+- Enables multi-party compliance
+
+**Usage scenario**
+
+Two operators maintain separate whitelists using `addRule`/`setRules` and each child rule’s `addAddress`. A transfer between Alice and Bob is allowed if at least one child whitelist returns `true` for both via `areAddressesListed`; otherwise `detectTransferRestriction` rejects it.
+
+
+
+##### Architecture
+
+This rule inherits from `RuleEngineValidationCommon`. Thus the whitelist rules are managed with the same architecture and code than for the ruleEngine. For example, rules are added with the functions `setRules` or `addRule`.
+
+
+
+
+
+
+
+#### Blacklist
+
+Opposite of whitelist:
+
+- Transfer fails if **either** address is blacklisted.
+
+**Usage scenario**
+
+The operator sets `RuleBlacklist` on the token. The issuer tries to transfer to Bob; `detectTransferRestriction` passes. The operator calls `addAddress(Bob)`. A subsequent transfer to Bob is rejected until `removeAddress(Bob)` is called.
+
+
+
+#### ERC-2980 (Whitelist + Frozenlist)
+
+Implements the [ERC-2980](https://eips.ethereum.org/EIPS/eip-2980) Swiss Compliant Asset Token transfer restriction using two independent address lists managed in a single rule:
+
+- **Whitelist**: only whitelisted addresses may *receive* tokens. Senders do not need to be whitelisted and may freely transfer tokens they already hold.
+- **Frozenlist**: frozen addresses are completely blocked — they can neither send nor receive tokens. Additionally, a frozen address acting as a `transferFrom` spender will have the transfer rejected (code 62), even if `from` and `to` are not frozen.
+- **Priority**: frozenlist is checked first. If `from`, `to`, or `spender` is frozen, the transfer is rejected regardless of whitelist membership.
+- **Mint/burn handling**: governed by the explicit `allowMint` / `allowBurn` flags, never by whitelisting `address(0)`. The zero address can never enter either list, so the **mandatory ERC-2980 getters** `whitelist(address(0))` / `frozenlist(address(0))` always return `false`.
+ - `allowMintBurn = false` (default-safe): mint is refused with code **64**, burn with code **65**.
+ - `allowMintBurn = true`: both permitted. A permitted mint still requires the recipient to be whitelisted and not frozen; a permitted burn still requires the sender not to be frozen.
+ - Independently settable afterwards via `setAllowMint(bool)` / `setAllowBurn(bool)`.
+- Constructors:
+ - `RuleERC2980(address admin, address forwarderIrrevocable, bool allowMintBurn)`
+ - `RuleERC2980Ownable2Step(address owner, address forwarderIrrevocable, bool allowMintBurn)`
+
+
+
+Restriction codes:
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_ADDRESS_FROM_IS_FROZEN` | 60 | Sender is frozen |
+| `CODE_ADDRESS_TO_IS_FROZEN` | 61 | Recipient is frozen |
+| `CODE_ADDRESS_SPENDER_IS_FROZEN` | 62 | Spender is frozen |
+| `CODE_ADDRESS_TO_NOT_WHITELISTED` | 63 | Recipient is not whitelisted |
+| `CODE_MINT_NOT_ALLOWED` | 64 | Minting is disabled (`allowMint == false`) |
+| `CODE_BURN_NOT_ALLOWED` | 65 | Burning is disabled (`allowBurn == false`) |
+
+**Deviation from spec**: the ERC-2980 `Whitelistable` / `Freezable` example interfaces define single-address management functions that return `bool` and do not revert on duplicates or missing entries. This implementation reverts on invalid single-item operations, consistent with the codebase convention. Batch operations remain non-reverting **for duplicates and missing entries**, which are skipped — but **`address(0)` reverts the whole batch**, as it does on every add path in the library (see [Zero address in batch operations](#zero-address-in-batch-operations)).
+
+**Usage scenario**
+
+The operator deploys `RuleERC2980` and chooses `allowBurn` according to the redemption policy. The issuer whitelists Alice with `addWhitelistAddress(Alice)`. A transfer to Alice succeeds. The compliance officer freezes Bob with `addFrozenlistAddress(Bob)`. Any transfer from or to Bob is now rejected even if Bob was previously whitelisted.
+
+#### Sanction list with Chainalysis
+
+Uses the [Chainalysis](https://www.chainalysis.com/) Oracle to reject transfers involving sanctioned addresses.
+
+- Checks lists for: **US**, **EU**, and **UN** sanctions.
+- Documentation: *Chainalysis Oracle for sanctions screening*
+- If `from` or `to` is sanctioned, transfer is rejected.
+
+The documentation and contract addresses are available here: [Chainalysis oracle for sanctions screening](https://go.chainalysis.com/chainalysis-oracle-docs.html).
+
+
+
+**Example**
+
+During a transfer, if either address (from or to) is in the sanction list of the Oracle, the rule will return false, and the transfer will be rejected by the CMTAT.
+
+**Usage scenario**
+
+The operator sets the Chainalysis oracle with `setSanctionListOracle`. The token’s transfer path calls `detectTransferRestriction`; if the oracle flags `from` or `to`, the transfer is rejected. Calling `clearSanctionListOracle` disables checks.
+
+#### Max total supply
+
+Limits minting so that total supply never exceeds a configured maximum. Transfers and burns are not affected; only mints (`from == address(0)`) are checked.
+
+
+
+**Usage scenario**
+
+The operator deploys `RuleMaxTotalSupply` with `setMaxTotalSupply(1_000_000)` and sets the token with `setTokenContract`. When the issuer mints and `totalSupply + amount` exceeds the limit, `detectTransferRestriction` rejects the mint. Transfers between holders still pass.
+
+#### Max balance per holder
+
+Caps how many tokens a single address may hold. One cap applies to every holder, and the operator may exempt
+specific addresses from it. The **receiver** is screened: a transfer is rejected when
+`balanceOf(to) + value > maxBalance`. Mints are covered by the same check; burns are exempt, and the sender is
+never screened because sending tokens away can only lower a balance.
+
+
+
+> ⚠️ **Do not deploy this rule alone.** The cap counts tokens per *address*, so an investor holding through two
+> addresses holds twice the cap and no rule objects. Pair it with a rule that admits one address per investor
+> (`RuleWhitelist`, `RuleReceiverWhitelist` or `RuleIdentityRegistry`) **and** an onboarding policy of one
+> admitted address per legal entity — a whitelist alone does not close it, since the operator can admit both
+> wallets. See [RuleMaxBalance.md](./technical/contracts/RuleMaxBalance.md).
+
+**Usage scenario**
+
+An issuer must keep any single investor below 5% of a 1,000,000-token issue. They deploy `RuleWhitelist` and
+`RuleMaxBalance(admin, cmtat, 50_000)` in the same RuleEngine, admit exactly one address per onboarded
+investor, and exempt the treasury address holding the unsold allocation. An investor at 50,000 tokens can still
+sell, and can buy again once below the cap; a mint that would push them over is rejected with code `82`.
+
+#### Chainlink Proof of Reserve
+
+Limits minting so that the total supply never exceeds the reserves actually backing the token. Before every mint the rule reads the latest reserve value from a [Chainlink Proof of Reserve](https://docs.chain.link/data-feeds/proof-of-reserve) data feed (any `AggregatorV3Interface`), scales it from the feed's decimals to the token's, and rejects the mint if `totalSupply + amount` would exceed it.
+
+The rule is modelled on Chainlink's [`SecureMintPolicy`](https://docs.chain.link/ace/reference/policy-library/secure-mint-policy) from the ACE policy library, re-expressed as an ERC-1404 / ERC-3643 compliance rule and deliberately simplified: the ACE policy's configurable reserve margin is not carried over.
+
+- **Limit = reserves, exactly** — no margin, buffer or headroom parameter. For a safety cushion, report conservative reserves on the feed or compose with `RuleMaxTotalSupply`.
+- **Staleness threshold** — `maxStalenessSeconds` rejects mints when the feed has not been updated recently; pick it from the feed's heartbeat. `0` disables the check.
+- **Mints only** — transfers and burns always pass, including while the feed is stale or unavailable, so a lapsed feed never traps holders.
+- **Views never revert** — an unreadable feed returns code 79, an unusable answer 77, a stale feed 76, and a token whose `totalSupply()` reverts 78.
+
+Use `maxBackedSupply()` to preview the current limit without simulating a mint.
+
+
+
+**Usage scenario**
+
+The operator deploys `RuleChainlinkPoR` with the token, its decimals, the Proof of Reserve feed and a staleness threshold slightly above the feed heartbeat. With reserves reported at 1 000 units, at most 1 000 tokens may exist; a mint beyond that is rejected with code 75. When the custodian deposits more and the feed updates, the headroom reopens with no rule reconfiguration. Full details in [RuleChainlinkPoR.md](./technical/contracts/RuleChainlinkPoR.md).
+
+#### Identity registry
+
+**ERC-3643 conformant: only the RECEIVER is verified.** The specification mandates exactly one identity check — *"The receiver MUST be whitelisted on the Identity Registry and verified"* — and states that `transferFrom` "works the same way", that `mint` "only require[s] the receiver", and that `burn` "bypasses all checks on eligibility". The **sender**, the **spender** and the **minter** are therefore **not** verified by default.
+
+Checking the sender is deliberately avoided: ERC-3643 screens only the receiver precisely so that an investor whose identity lapses can still **exit their position** by sending to a verified counterparty. Screening the sender would trap them — unable to receive *and* unable to send.
+
+Stricter screening is available as an **explicit opt-in**, never a silent default:
+- `checkSender` — also verify the sender (stricter than ERC-3643).
+- `checkSpender` — also verify the spender on `transferFrom` (stricter than ERC-3643). Mint and burn stay exempt regardless.
+
+Constructors: `RuleIdentityRegistry(address admin, address identityRegistry, bool checkSender, bool checkSpender)` — pass `false, false` for the conformant default. Both flags are settable afterwards via `setCheckSender(bool)` / `setCheckSpender(bool)`.
+
+
+
+**Usage scenario**
+
+The operator calls `setIdentityRegistry(registry)`. The issuer attempts a transfer to Alice; `detectTransferRestriction` consults `isVerified` and rejects if Alice is unverified. After the registry marks Alice verified, the transfer succeeds. Calling `clearIdentityRegistry` disables checks.
+
+### Read-Write (Operation) rule
+
+There are three operation rules available: `RuleConditionalTransferLight`, `RuleConditionalTransferLightMultiToken`, and `RuleMintAllowance`.
+
+#### Conditional transfer (light)
+
+This rule requires that transfers must be approved by an operator before being executed. It hashes `(from, to, value)` to track approvals and allows the same transfer to be approved multiple times. Each successful transfer consumes one approval, applying a write operation on the blockchain. Mints (`from == address(0)`) and burns (`to == address(0)`) are exempt and always pass without requiring approval.
+
+
+
+**Usage scenario**
+
+An operator calls `approveTransfer(from, to, value)`. The compliance manager binds exactly one token with `bindToken(token)`; attempting to bind a second token reverts. The token calls `detectTransferRestriction` (passes) and later `transferred` to consume the approval. Without approval, `detectTransferRestriction` returns code 46 and the transfer is rejected. The operator can revoke with `cancelTransferApproval`. To migrate to a different token, the compliance manager must first call `unbindToken` before binding the new one.
+
+#### Mint allowance
+
+This rule enforces a per-minter mint quota for one bound RuleEngine/token at a time. An operator sets the number of tokens each minter address is allowed to mint via `setMintAllowance(minter, amount)`. Every successful mint reduces the minter's remaining quota. The operator can adjust quotas at any time with `increaseMintAllowance` / `decreaseMintAllowance`. Regular transfers and burns are not restricted.
+
+Compatibility warning: `RuleMintAllowance` does not enforce quotas for a token that only calls the standard ERC-3643 3-arg compliance functions. It requires the CMTAT/RuleEngine spender-aware path so the minter address is passed as `spender`.
+
+For the same reason, it does not advertise the full ERC-3643 `ICompliance` interface through ERC-165; the 3-arg callbacks alone cannot enforce the mint quota.
+
+> ⚠️ **`canTransfer` / `detectTransferRestriction` are not authoritative for this rule** — they are hardcoded to "allowed" because the 3-arg signature has no minter identity, so they disagree with enforcement. Pre-flight a mint with the spender-aware view `canTransferFrom(minter, address(0), to, value)` (or `detectTransferRestrictionFrom`). See [RuleMintAllowance.md](./technical/contracts/RuleMintAllowance.md#eligibility-views-which-one-is-authoritative).
+
+**Usage scenario**
+
+The compliance manager binds the rule to the RuleEngine with `bindToken(ruleEngine)`. Attempting to bind a second RuleEngine/token reverts until the current binding is removed with `unbindToken`. The operator assigns `setMintAllowance(alice, 100_000e18)`. Alice's mints deduct from her quota through `transferred(alice, address(0), recipient, amount)`; once exhausted, further mints revert with code 70 until the operator increases the quota.
+
+#### Conditional transfer (light, multi-token)
+
+This variant scopes approvals by token address. It hashes `(token, from, to, value)` and supports multiple bound tokens in a single rule instance. Each successful transfer consumes one approval in the calling token namespace. Mints (`from == address(0)`) and burns (`to == address(0)`) remain exempt.
+
+**Usage scenario**
+
+An operator calls `approveTransfer(tokenA, from, to, value)` for `tokenA`. A transfer on `tokenA` succeeds and consumes the approval. The same `(from, to, value)` transfer on `tokenB` is still rejected until separately approved with `approveTransfer(tokenB, from, to, value)`.
+
+## Access Control
+
+The module `AccessControlModuleStandalone` implements RBAC access control by inheriting from OpenZeppelin's `AccessControlEnumerable`.
+
+Each rule implements its own access control by inheriting from `AccessControlModuleStandalone`. The default admin is the address passed as `admin` to the constructor at deployment.
+
+#### `DEFAULT_ADMIN_ROLE` implicit role behaviour
+
+`AccessControlModuleStandalone` overrides OpenZeppelin's `hasRole` so that any account holding `DEFAULT_ADMIN_ROLE` returns `true` for **every** role check. This is intentional: the OpenZeppelin `DEFAULT_ADMIN_ROLE` holder can already grant itself any role at any time, so treating it as implicitly holding all roles from the start removes unnecessary ceremony and makes access management easier in practice.
+
+Practical consequences integrators must be aware of:
+
+- **`grantRole` to a default admin is a no-op.** `_grantRole` checks `!hasRole(role, account)` before writing storage; since the admin already returns `true` via the override, the storage write and the `RoleGranted` event are skipped. The admin will **not** appear in `getRoleMember` / `getRoleMemberCount` enumerations for non-default roles unless the role was explicitly granted before the admin was set.
+- **`revokeRole` / `renounceRole`** on a non-default role for a default admin are misleading. They emit `RoleRevoked` and clear the storage flag, but `hasRole` continues to return `true` because the account still holds `DEFAULT_ADMIN_ROLE`. The effective privilege is unchanged. To fully remove access, `DEFAULT_ADMIN_ROLE` itself must be revoked.
+- **Off-chain monitoring** should use `hasRole` queries, not role-membership events or enumeration, to determine the effective privileges of admin accounts.
+
+See also [docs.openzeppelin.com - AccessControl](https://docs.openzeppelin.com/contracts/5.x/api/access#AccessControl)
+
+### Role Summary
+
+| Role | Hash | Functions (by rule) |
+| --- | --- | --- |
+| `DEFAULT_ADMIN_ROLE` | `0x0000000000000000000000000000000000000000000000000000000000000000` | `grantRole`, `revokeRole`, `renounceRole` (all AccessControl rules); `setCheckSpender` (RuleWhitelist, RuleWhitelistWrapper); `setMaxTotalSupply`, `setTokenContract` (RuleMaxTotalSupply); `setReservesFeed`, `setTokenMetadata`, `setMaxStalenessSeconds` (RuleChainlinkPoR); `setIdentityRegistry`, `clearIdentityRegistry` (RuleIdentityRegistry) |
+| `ADDRESS_LIST_ADD_ROLE` | `0x1b03c849816e077359373cf0a8d6d8f741d643bc1e95273ffe11515f83bebf61` | `addAddress`, `addAddresses` (RuleWhitelist, RuleBlacklist) |
+| `ADDRESS_LIST_REMOVE_ROLE` | `0x1b94c92b564251ed6b49246d9a82eb7a486b6490f3b3a3bf3b28d2e99801f3ec` | `removeAddress`, `removeAddresses` (RuleWhitelist, RuleBlacklist) |
+| `SANCTIONLIST_ROLE` | `0x30842281ac34bdc7d568c7ab276f84ba6fc1a1de1ae858b0afd35e716fb0650d` | `setSanctionListOracle`, `clearSanctionListOracle` (RuleSanctionsList) |
+| `RULES_MANAGEMENT_ROLE` | `0xea5f4eb72290e50c32abd6c23e45de3d8300b3286e1cbc2e293114b92e034e5e` | `setRules`, `clearRules`, `addRule`, `removeRule` (RuleWhitelistWrapper) |
+| `OPERATOR_ROLE` | `0x97667070c54ef182b0f5858b034beac1b6f3089aa2d3188bb1e8929f4fa9b929` | `approveTransfer`, `cancelTransferApproval` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken) |
+| `COMPLIANCE_MANAGER_ROLE` | `0xe5c50d0927e06141e032cb9a67e1d7092dc85c0b0825191f7e1cede600028568` | `bindToken`, `unbindToken` (RuleConditionalTransferLight / RuleConditionalTransferLightMultiToken / RuleMintAllowance) |
+| `ALLOWANCE_OPERATOR_ROLE` | `0x86a2482724302deea267bc1ca14032806c318aeaf8d1e0d445a6fb7e7c997beb` | `setMintAllowance`, `increaseMintAllowance`, `decreaseMintAllowance` (RuleMintAllowance) |
+| `WHITELIST_ADD_ROLE` | `0x77c0b4c0975a0b0417d8ce295502737b95fee8923755fed0cce952907a1861ed` | `addWhitelistAddress`, `addWhitelistAddresses` (RuleERC2980) |
+| `WHITELIST_REMOVE_ROLE` | `0xf4d11a530c5b90f459c6ab1e335d3d77156b8ff3093308e4fca6d100ee87ade9` | `removeWhitelistAddress`, `removeWhitelistAddresses` (RuleERC2980) |
+| `FROZENLIST_ADD_ROLE` | `0xc52c49807a071974b9260f4b553ee09bd9fd85f687d8d4cc3232de7104ff7835` | `addFrozenlistAddress`, `addFrozenlistAddresses` (RuleERC2980) |
+| `FROZENLIST_REMOVE_ROLE` | `0x8be92b33a413d98540bfb0edc9129253db6d924f6c2e32c4b7809d237f7b2aaa` | `removeFrozenlistAddress`, `removeFrozenlistAddresses` (RuleERC2980) |
+
+### Ownable2Step variants
+
+For simpler ownership-based control, `Ownable2Step` variants (two-step ownership transfer) are available:
+
+- `RuleWhitelistOwnable2Step`
+- `RuleReceiverWhitelistOwnable2Step`
+- `RuleBlacklistOwnable2Step`
+- `RuleWhitelistWrapperOwnable2Step`
+- `RuleSanctionsListOwnable2Step`
+- `RuleIdentityRegistryOwnable2Step`
+- `RuleMaxTotalSupplyOwnable2Step`
+- `RuleChainlinkPoROwnable2Step`
+- `RuleERC2980Ownable2Step`
+- `RuleConditionalTransferLightOwnable2Step`
+- `RuleConditionalTransferLightMultiTokenOwnable2Step`
+- `RuleMintAllowanceOwnable2Step`
+
+`RuleConditionalTransferLightOwnable2Step` now grants approval and execution permissions exclusively to the owner.
+All `Ownable2Step` variants enforce access using OpenZeppelin's `onlyOwner` modifier.
+All `Ownable2Step` variants also advertise ERC-165 support for `IERC165` (`0x01ffc9a7`), ERC-173 ownership (`0x7f5828d0`), and Ownable2Step handover (`0x9ab669ef`).
+
+### Address List
+
+Common access control between the blacklist rule and whitelist rule.
+
+These roles are listed above in the Role Summary table.
+
+## Toolchains and Usage
+
+This repository is developed and tested with [Foundry](https://book.getfoundry.sh); a Hardhat config is also present for compilation and a small smoke test. Build settings (`foundry.toml` / `hardhat.config.js`): solc `v0.8.36`, EVM `Prague`, optimizer on (200 runs).
+
+### Main commands
+
+| Task | Command |
+| --- | --- |
+| Install / update submodules | `forge install` · `forge update` |
+| Build | `forge build` |
+| Contract sizes | `forge compile --sizes` |
+| Run all tests | `forge test` |
+| Run one test | `forge test --match-contract --match-test ` |
+| Gas report | `forge test --gas-report` |
+| Gas snapshot | `forge snapshot` (check only: `forge snapshot --check`) |
+| Coverage | `forge coverage` |
+| Coverage report ([`doc/coverage`](./coverage/)) | `forge coverage --no-match-coverage "(script\|mocks\|test)" --report lcov && genhtml lcov.info --branch-coverage --prefix "$PWD/" --output-dir coverage` |
+| Invariant suite only | `forge test --match-path "test/invariant/*"` |
+| Format | `forge fmt` |
+| Deploy a script | `forge script script/.s.sol --rpc-url --account ` |
+
+### Invariant testing
+
+The two **stateful (operation) rules** — `RuleConditionalTransferLight` and `RuleMintAllowance` — are covered by a handler-driven `StdInvariant` suite in [`test/invariant/`](../test/invariant/), which fuzzes long randomly-ordered call sequences and re-checks four invariants after every step (8 192 calls each, `fail_on_revert = true`):
+
+| Invariant | Asserts |
+| --- | --- |
+| `invariant_approvalConservation` | `totalApproved − totalCancelled − totalExecuted == Σ approvalCounts` — approvals are never double-spent or lost |
+| `invariant_noApprovalExceedsTotalRecorded` | `Σ approvalCounts ≤ totalApproved` |
+| `invariant_allowanceMatchesGhost` | the on-chain mint quota exactly matches an independently-computed ghost mirror, after any interleaving |
+| `invariant_mintedNeverExceedsCredited` | `Σ minted ≤ Σ credited` |
+
+Both suites are **mutation-verified**: injecting an approval double-spend or an off-by-one quota deduction makes them fail. Validation rules are read-only and hold no per-transfer state, so they are covered by unit and fuzz tests instead.
+
+Full details — handler architecture, ghost variables, the negative controls, the coverage map against the threat-model invariants, and how to add a new one — are in **[doc/technical/guides/INVARIANT_TESTS.md](./technical/guides/INVARIANT_TESTS.md)**.
+
+Deployment scripts: `script/DeployCMTATWithWhitelist.s.sol`, `script/DeployCMTATWithBlacklist.s.sol`, `script/DeployCMTATWithBlacklistAndSanctionsList.s.sol`.
+
+> **Deployment key security:** avoid passing `--private-key` on the command line (visible in shell history and to any process that can read `/proc`). Prefer hardware wallets (`--ledger`, `--trezor`) or encrypted keystores (`--account `). See [Foundry best practices](https://www.getfoundry.sh/best-practices).
+
+For the full toolchain guide — dependency versions, Hardhat commands, HTML coverage generation, the gas-benchmark workflow, and the generic Forge / Cast / Anvil / Chisel reference — see **[doc/FOUNDRY.md](./FOUNDRY.md)** and the [Foundry book](https://book.getfoundry.sh/).
+
+## API
+
+### IRuleEngine
+
+All rules implement `IRuleEngine`. The behaviour of `transferred()` differs by rule type:
+
+- **Validation rules** implement `transferred()` as `view`: it re-runs the restriction check and reverts if the transfer would be blocked, but does not modify state.
+- **Operation rules** implement `transferred()` as a state-mutating function: it updates storage as part of the transfer (e.g. consuming an approval in `RuleConditionalTransferLight`).
+
+#### transferred
+
+```
+function transferred(address spender, address from, address to, uint256 value)
+ external;
+```
+
+Called by a token or RuleEngine after a transfer. For validation rules, enforces the restriction check. For operation rules, mutates internal state.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ------------------------------------------------------------ |
+| `spender` | `address` | Address executing the transfer (owner, operator, or approved). |
+| `from` | `address` | Current token holder. |
+| `to` | `address` | Recipient address. |
+| `value` | `uint256` | Amount transferred. |
+
+------
+
+### IERC1404
+
+#### detectTransferRestriction
+
+```
+function detectTransferRestriction(address from, address to, uint256 value)
+ external
+ view
+ returns (uint8);
+```
+
+Returns a restriction code describing why a transfer is blocked.
+
+##### Parameters
+
+| Name | Type | Description |
+| ------- | --------- | ------------------------- |
+| `from` | `address` | Sender address. |
+| `to` | `address` | Recipient address. |
+| `value` | `uint256` | Amount being transferred. |
+
+##### Returns
+
+| Name | Type | Description |
+| ----- | ------- | ---------------------------------------- |
+| `0` | `uint8` | Transfer allowed. |
+| other | `uint8` | Implementation-defined restriction code. |
+
+------
+
+#### messageForTransferRestriction
+
+```
+function messageForTransferRestriction(uint8 restrictionCode)
+ external
+ view
+ returns (string memory);
+```
+
+Returns a human-readable message associated with a restriction code.
+
+##### Parameters
+
+| Name | Type | Description |
+| ----------------- | ------- | --------------------------------------------------------- |
+| `restrictionCode` | `uint8` | Restriction code returned by `detectTransferRestriction`. |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | -------- | ------------------------------------- |
+| `message` | `string` | Explanation for the restriction code. |
+
+------
+
+### IERC1404Extend
+
+#### REJECTED_CODE_BASE
+
+```
+enum REJECTED_CODE_BASE {
+ TRANSFER_OK,
+ TRANSFER_REJECTED_DEACTIVATED,
+ TRANSFER_REJECTED_PAUSED,
+ TRANSFER_REJECTED_FROM_FROZEN,
+ TRANSFER_REJECTED_TO_FROZEN,
+ TRANSFER_REJECTED_SPENDER_FROZEN,
+ TRANSFER_REJECTED_FROM_INSUFFICIENT_ACTIVE_BALANCE
+}
+```
+
+Base transfer restriction codes used by ERC-1404 extensions.
+
+------
+
+#### detectTransferRestrictionFrom
+
+```
+function detectTransferRestrictionFrom(
+ address spender,
+ address from,
+ address to,
+ uint256 value
+)
+ external
+ view
+ returns (uint8);
+```
+
+Restriction code for transfers performed by a spender (approved operator).
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | -------------------------------- |
+| `spender` | `address` | Address performing the transfer. |
+| `from` | `address` | Current token owner. |
+| `to` | `address` | Recipient address. |
+| `value` | `uint256` | Transfer amount. |
+
+##### Returns
+
+| Name | Type | Description |
+| ------ | ------- | ---------------------------------------------------- |
+| `code` | `uint8` | 0 if transfer allowed, otherwise a restriction code. |
+
+------
+
+### IERC7551Compliance
+
+#### canTransferFrom
+
+```
+function canTransferFrom(address spender, address from, address to, uint256 value)
+ external
+ view
+ returns (bool);
+```
+
+Determines if a spender-initiated transfer is permitted.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | -------------------------- |
+| `spender` | `address` | Caller executing transfer. |
+| `from` | `address` | Token owner. |
+| `to` | `address` | Recipient. |
+| `value` | `uint256` | Amount. |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | ------ | ----------------------------- |
+| `allowed` | `bool` | `true` if transfer permitted. |
+
+------
+
+### IERC3643ComplianceRead
+
+#### canTransfer
+
+```
+function canTransfer(address from, address to, uint256 value)
+ external
+ view
+ returns (bool isValid);
+```
+
+Returns whether a transfer is compliant.
+
+##### Parameters
+
+| Name | Type | Description |
+| ------- | --------- | ---------------- |
+| `from` | `address` | Sender. |
+| `to` | `address` | Receiver. |
+| `value` | `uint256` | Transfer amount. |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | ------ | -------------------- |
+| `isValid` | `bool` | `true` if compliant. |
+
+------
+
+### IERC3643IComplianceContract
+
+#### transferred
+
+```
+function transferred(address from, address to, uint256 value)
+ external;
+```
+
+Hook invoked during an ERC-20 token transfer.
+
+##### Parameters
+
+| Name | Type | Description |
+| ------- | --------- | ------------------- |
+| `from` | `address` | Previous owner. |
+| `to` | `address` | New owner. |
+| `value` | `uint256` | Amount transferred. |
+
+### Address List Management
+
+> This API is common to whitelist and blacklist rules
+
+#### addAddresses
+
+```
+function addAddresses(address[] calldata targetAddresses)
+ public
+ onlyAddressListAdd
+```
+
+##### Description
+
+Adds multiple addresses to the internal address set.
+
+##### Details
+
+- Does **not** revert if one or more addresses are already listed.
+- Restricted by the rule's access control policy (role- or owner-based).
+- Emits `AddAddresses`. Skipped/added counts are not emitted to keep gas cost minimal.
+
+##### Parameters
+
+| Name | Type | Description |
+| ----------------- | ----------- | ------------------------------------------ |
+| `targetAddresses` | `address[]` | Array of addresses to be added to the set. |
+
+------
+
+#### removeAddresses
+
+```
+function removeAddresses(address[] calldata targetAddresses)
+ public
+ onlyAddressListRemove
+```
+
+##### Description
+
+Removes multiple addresses from the internal set.
+
+##### Details
+
+- Does **not** revert if an address is not currently listed.
+- Restricted by the rule's access control policy (role- or owner-based).
+- Emits `RemoveAddresses`. Skipped/removed counts are not emitted to keep gas cost minimal.
+
+##### Parameters
+
+| Name | Type | Description |
+| ----------------- | ----------- | --------------------------------- |
+| `targetAddresses` | `address[]` | Array of addresses to be removed. |
+
+------
+
+#### addAddress
+
+```
+function addAddress(address targetAddress)
+ public
+ onlyAddressListAdd
+```
+
+##### Description
+
+Adds a **single** address to the set.
+
+##### Details
+
+- **Reverts** if the address is already listed.
+- Restricted by the rule's access control policy (role- or owner-based).
+- Emits an `AddAddress` event.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------------- | --------- | --------------- |
+| `targetAddress` | `address` | Address to add. |
+
+------
+
+#### removeAddress
+
+```
+function removeAddress(address targetAddress)
+ public
+ onlyAddressListRemove
+```
+
+##### Description
+
+Removes a **single** address from the set.
+
+##### Details
+
+- **Reverts** if the address is not listed.
+- Restricted by the rule's access control policy (role- or owner-based).
+- Emits a `RemoveAddress` event.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------------- | --------- | ------------------ |
+| `targetAddress` | `address` | Address to remove. |
+
+------
+
+#### listedAddressCount
+
+```
+function listedAddressCount() public view returns (uint256 count)
+```
+
+##### Description
+
+Returns the total number of addresses currently listed in the internal set.
+
+##### Returns
+
+| Name | Type | Description |
+| ------- | --------- | --------------------------------- |
+| `count` | `uint256` | Total number of listed addresses. |
+
+------
+
+##### contains
+
+```
+function contains(address targetAddress)
+ public
+ view
+ override(IIdentityRegistryContains)
+ returns (bool isListed)
+```
+
+##### Description
+
+Checks whether a specific address is listed.
+ Implements `IIdentityRegistryContains`.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------------- | --------- | ----------------- |
+| `targetAddress` | `address` | Address to check. |
+
+##### Returns
+
+| Name | Type | Description |
+| ---------- | ------ | --------------------------------------------------- |
+| `isListed` | `bool` | `true` if the address is listed, otherwise `false`. |
+
+------
+
+#### isAddressListed
+
+```
+function isAddressListed(address targetAddress)
+ public
+ view
+ returns (bool isListed)
+```
+
+##### Description
+
+Returns whether a given address is included in the internal set.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------------- | --------- | ----------------- |
+| `targetAddress` | `address` | Address to check. |
+
+##### Returns
+
+| Name | Type | Description |
+| ---------- | ------ | --------------- |
+| `isListed` | `bool` | Listing status. |
+
+------
+
+#### areAddressesListed
+
+```
+function areAddressesListed(address[] memory targetAddresses)
+ public
+ view
+ returns (bool[] memory results)
+```
+
+##### Description
+
+Checks the listing status of multiple addresses in a single call.
+
+##### Parameters
+
+| Name | Type | Description |
+| ----------------- | ----------- | ---------------------------- |
+| `targetAddresses` | `address[]` | Array of addresses to check. |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | -------- | --------------------------------------------------- |
+| `results` | `bool[]` | Array of boolean listing results, aligned by index. |
+
+#### Details
+
+##### Null address
+
+It is possible to add the null address (0x0) to the address list. In a whitelist, this enables mint/burn flows (since `from`/`to` can be zero). In a blacklist, adding `0x0` blocks mint/burn.
+For `RuleWhitelist`, you can also pre-list `0x0` at deployment using the constructor parameter `allowMintBurn=true`.
+
+##### Duplicate address
+
+**addAddress**
+If the address already exists, the transaction is reverted to save gas.
+**addAddresses**
+If one of the addresses already exist, there is no change for this address. The transaction remains valid (no revert).
+
+##### NonExistent Address
+
+**removeAddress**
+If the address does not exist in the whitelist, the transaction is reverted to save gas.
+**removeAddresses**
+If the address does not exist in the whitelist, there is no change for this address. The transaction remains valid (no revert).
+
+
+
+### IERC7943NonFungibleCompliance
+
+Compliance interface for ERC-721 / ERC-1155–style non-fungible assets. It is implemented by the address-screening validation rules only: the operation rules (such as `RuleConditionalTransferLight`) and the supply-cap rules `RuleMaxTotalSupply` and `RuleChainlinkPoR` are ERC-20 only and do not implement this interface.
+ For ERC-721, `amount` must always be `1`.
+
+------
+
+#### Functions
+
+| Name | Description |
+| --------------- | ------------------------------------------------------------ |
+| **canTransfer** | Verifies whether a transfer is permitted according to the token’s compliance rules. |
+
+------
+
+#### canTransfer
+
+```
+function canTransfer(
+ address from,
+ address to,
+ uint256 tokenId,
+ uint256 amount
+) external view returns (bool allowed)
+```
+
+##### Description
+
+Verifies whether a token transfer is permitted according to the rule-based compliance logic.
+
+##### Details
+
+- Must not modify state.
+- May enforce checks such as allowlists, blocklists, freezing, transfer limits, regulatory rules.
+- Must return `false` if the transfer is not permitted.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ----------------------------------------- |
+| `from` | `address` | Current token owner. |
+| `to` | `address` | Receiving address. |
+| `tokenId` | `uint256` | Token ID. |
+| `amount` | `uint256` | Transfer amount (always `1` for ERC-721). |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | ------ | ------------------------------------------------- |
+| `allowed` | `bool` | `true` if transfer is allowed; otherwise `false`. |
+
+------
+
+### IERC7943NonFungibleComplianceExtend
+
+Extended compliance interface for ERC-721 / ERC-1155 non-fungible assets. It is implemented by the address-screening validation rules only: the operation rules (such as `RuleConditionalTransferLight`) and the supply-cap rules `RuleMaxTotalSupply` and `RuleChainlinkPoR` are ERC-20 only and do not implement this interface.
+ Adds restriction-code reporting, spender-aware checks, and a post-transfer hook.
+
+For ERC-721, `amount` / `value` must always be `1`.
+
+------
+
+#### Functions
+
+| Name | Description |
+| --------------------------------- | ------------------------------------------------------------ |
+| **detectTransferRestriction** | Returns a restriction code indicating why a transfer is blocked. |
+| **detectTransferRestrictionFrom** | Returns a restriction code for a spender-initiated transfer. |
+| **canTransferFrom** | Checks whether a spender-initiated transfer is allowed. |
+| **transferred** | Notifies the compliance engine that a transfer has occurred. |
+
+------
+
+#### detectTransferRestriction
+
+```
+function detectTransferRestriction(
+ address from,
+ address to,
+ uint256 tokenId,
+ uint256 amount
+) external view returns (uint8 code)
+```
+
+##### Description
+
+Returns a restriction code describing whether and why a transfer is blocked.
+
+##### Details
+
+- Must not modify state.
+- Must return `0` when the transfer is allowed.
+- Non-zero codes should follow ERC-1404 or similar standards.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ---------------------------------- |
+| `from` | `address` | Current token holder. |
+| `to` | `address` | Receiving address. |
+| `tokenId` | `uint256` | Token ID. |
+| `amount` | `uint256` | Transfer amount (`1` for ERC-721). |
+
+##### Returns
+
+| Name | Type | Description |
+| ------ | ------- | --------------------------------------------- |
+| `code` | `uint8` | `0` if allowed; otherwise a restriction code. |
+
+------
+
+#### detectTransferRestrictionFrom
+
+```
+function detectTransferRestrictionFrom(
+ address spender,
+ address from,
+ address to,
+ uint256 tokenId,
+ uint256 value
+) external view returns (uint8 code)
+```
+
+##### Description
+
+Returns a restriction code for a transfer initiated by a spender (approved operator or owner).
+
+##### Details
+
+- Must not modify state.
+- Must return `0` when the transfer is permitted.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ---------------------------------- |
+| `spender` | `address` | Address performing the transfer. |
+| `from` | `address` | Current owner. |
+| `to` | `address` | Recipient address. |
+| `tokenId` | `uint256` | Token ID being checked. |
+| `value` | `uint256` | Transfer amount (`1` for ERC-721). |
+
+##### Returns
+
+| Name | Type | Description |
+| ------ | ------- | ------------------------------------------- |
+| `code` | `uint8` | `0` if allowed; otherwise restriction code. |
+
+------
+
+#### canTransferFrom
+
+```
+function canTransferFrom(
+ address spender,
+ address from,
+ address to,
+ uint256 tokenId,
+ uint256 value
+) external view returns (bool allowed)
+```
+
+##### Description
+
+Checks whether a spender-initiated transfer is allowed under the compliance rules.
+
+##### Details
+
+- Must not modify state.
+- Should internally use `detectTransferRestrictionFrom`.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ---------------------------------------- |
+| `spender` | `address` | Address executing the transfer. |
+| `from` | `address` | Current owner. |
+| `to` | `address` | Recipient. |
+| `tokenId` | `uint256` | Token ID. |
+| `value` | `uint256` | Transfer amount (`1` for ERC-721 token). |
+
+##### Returns
+
+| Name | Type | Description |
+| --------- | ------ | ------------------------------ |
+| `allowed` | `bool` | `true` if transfer is allowed. |
+
+------
+
+#### transferred
+
+```
+function transferred(
+ address spender,
+ address from,
+ address to,
+ uint256 tokenId,
+ uint256 value
+) external
+```
+
+##### Description
+
+Signals to the compliance engine that a transfer has successfully occurred.
+
+##### Details
+
+- May modify compliance state.
+- For stateful rules, should be called by the token contract or RuleEngine after a successful transfer.
+- Rules may enforce access control on callers depending on their policy.
+
+##### Parameters
+
+| Name | Type | Description |
+| --------- | --------- | ---------------------------------------- |
+| `spender` | `address` | Address executing the transfer. |
+| `from` | `address` | Previous owner. |
+| `to` | `address` | New owner. |
+| `tokenId` | `uint256` | Token transferred. |
+| `value` | `uint256` | Transfer amount (`1` for ERC-721 token). |
+
+### RuleSanctionsList
+
+Compliance rule enforcing sanctions-screening for token transfers.
+ Integrates a sanctions-oracle (e.g., Chainalysis) to block transfers when the sender, recipient, or spender is sanctioned.
+
+------
+
+#### Constructor
+
+```solidity
+constructor(address admin, address forwarderIrrevocable, ISanctionsList sanctionContractOracle_)
+```
+
+Initializes access control, meta-transaction forwarder, and optionally the sanctions oracle.
+
+#### setSanctionListOracle
+
+```solidity
+function setSanctionListOracle(ISanctionsList sanctionContractOracle_)
+ public
+ virtual
+ onlyRole(SANCTIONLIST_ROLE)
+```
+
+Set the sanctions-oracle contract used for transfer-restriction checks.
+
+##### Parameters
+
+| Name | Type | Description |
+| ------------------------- | ---------------- | ------------------------------------------------------------ |
+| `sanctionContractOracle_` | `ISanctionsList` | Address of the sanctions-oracle. Zero address is not allowed; use `clearSanctionListOracle`. |
+
+##### Description
+
+Updates the sanctions-oracle contract reference.
+ This function may only be called by accounts granted the `SANCTIONLIST_ROLE`.
+ Passing the zero address reverts; use `clearSanctionListOracle` to disable checks.
+
+##### Emits
+
+| Event | Description |
+| -------------------------------- | ----------------------------------------------------- |
+| `SetSanctionListOracle(address)` | Emitted when the sanctions-oracle address is updated. |
+
+### RuleMaxTotalSupply
+
+Compliance rule that caps total token supply; only mints (`from == address(0)`) are restricted.
+
+------
+
+#### Constructor
+
+```solidity
+constructor(address admin, address tokenContract_, uint256 maxTotalSupply_)
+```
+
+Initializes access control, the token contract, and the max supply.
+
+#### setMaxTotalSupply
+
+```solidity
+function setMaxTotalSupply(uint256 newMaxTotalSupply)
+ public
+ virtual
+ onlyRole(DEFAULT_ADMIN_ROLE)
+```
+
+Updates the configured maximum supply.
+
+#### setTokenContract
+
+```solidity
+function setTokenContract(address tokenContract_)
+ public
+ virtual
+ onlyRole(DEFAULT_ADMIN_ROLE)
+```
+
+Sets the token contract used to read `totalSupply()`.
+
+### RuleChainlinkPoR
+
+Compliance rule that caps total token supply at the reserves reported by a Chainlink Proof of Reserve data feed; only mints (`from == address(0)`) are restricted.
+
+------
+
+#### Constructor
+
+```solidity
+constructor(
+ address admin,
+ address tokenContract_,
+ uint8 tokenDecimals_,
+ AggregatorV3Interface reservesFeed_,
+ uint256 maxStalenessSeconds_
+)
+```
+
+Initializes access control, the protected token and its decimals, the reserve feed (whose `decimals()` is cached) and the staleness threshold.
+
+#### setReservesFeed
+
+```solidity
+function setReservesFeed(AggregatorV3Interface newReservesFeed)
+ public
+ virtual
+ onlyRole(DEFAULT_ADMIN_ROLE)
+```
+
+Replaces the Proof of Reserve data feed. Reverts on the zero address, an address with no code, a reverting `decimals()`, or decimals above 36 — validation only, since the decimals are read live on every check rather than stored.
+
+#### setTokenMetadata
+
+```solidity
+function setTokenMetadata(address newTokenContract, uint8 newTokenDecimals)
+ public
+ virtual
+ onlyRole(DEFAULT_ADMIN_ROLE)
+```
+
+Sets the token contract used to read `totalSupply()` and the decimals used to scale the reserve answer. Reverts on the zero address, a non-contract address, or a token whose `totalSupply()` is not callable. The decimals are validated against the token's own `decimals()` when it exposes one.
+
+#### setMaxStalenessSeconds
+
+```solidity
+function setMaxStalenessSeconds(uint256 newMaxStalenessSeconds)
+ public
+ virtual
+ onlyRole(DEFAULT_ADMIN_ROLE)
+```
+
+Updates the maximum accepted age of the reserve data. `0` disables the staleness check.
+
+#### maxBackedSupply
+
+```solidity
+function maxBackedSupply() public view returns (uint8 restrictionCode, uint256 backedSupply)
+```
+
+Previews the supply currently backed by the reserves — the reported reserves scaled into token units. `restrictionCode` is `0` when the feed answer is usable, otherwise the code a mint would return. Never reverts.
+
+##### Emits
+
+| Event | Description |
+| ----------------------------------------- | -------------------------------------------------------- |
+| `ReservesFeedUpdated(address,uint8)` | Emitted when the data feed is set or replaced; the `uint8` records the decimals observed at configuration time. |
+| `TokenMetadataUpdated(address,uint8)` | Emitted when the protected token or its decimals change. |
+| `MaxStalenessSecondsUpdated(uint256)` | Emitted when the staleness threshold is updated. |
+
+### RuleConditionalTransferLight
+
+Operation rule requiring explicit approval before a transfer executes.
+
+------
+
+#### bindToken
+
+```solidity
+function bindToken(address token)
+ public
+ onlyRole(COMPLIANCE_MANAGER_ROLE)
+```
+
+Binds a token so it may call `transferred()`.
+
+#### unbindToken
+
+```solidity
+function unbindToken(address token)
+ public
+ onlyRole(COMPLIANCE_MANAGER_ROLE)
+```
+
+Revokes the token binding.
+
+#### approveTransfer
+
+```solidity
+function approveTransfer(address from, address to, uint256 value)
+ public
+ onlyTransferApprover
+```
+
+Approves one transfer (consumed on execution).
+
+#### cancelTransferApproval
+
+```solidity
+function cancelTransferApproval(address from, address to, uint256 value)
+ public
+ onlyTransferApprover
+```
+
+Removes one approval for the transfer.
+
+#### approveAndTransferIfAllowed
+
+```solidity
+function approveAndTransferIfAllowed(address from, address to, uint256 value)
+ public
+ onlyTransferApprover
+ returns (bool)
+```
+
+Approves then calls `SafeERC20.safeTransferFrom` on the bound token using this rule as spender.
+
+#### approvedCount
+
+```solidity
+function approvedCount(address from, address to, uint256 value)
+ public
+ view
+ returns (uint256)
+```
+
+Returns the number of approvals for the transfer hash.
+
+## Security
+
+### Manual Threat Model & Review (v0.4.0)
+
+The published report is [**`CLAUDE_AUDIT.md`**](./security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) — findings, invariant verification, access-control verification, what was remediated, and the open improvement backlog. It records the trust model and actors, the catalogued threats and invariants, and an explicit disposition for every threat ID.
+
+**Outcome: 0 Critical, 0 High, 0 Medium, 2 Low, 8 Informational.** Two hypotheses that would have been High were specifically probed and cleared: an ERC-2771 forwarder cannot impersonate a bound token (the operation rules deliberately do not inherit `ERC2771Context`), and the hand-rolled keccak preimage in `_transferHash` is injective.
+
+| ID | Severity | Summary |
+|---|---|---|
+| F-1 | Low | `RuleIdentityRegistry` screens the minter as `spender` on mint, unlike its three sibling allowlist rules, so issuance halts unless the minter is itself identity-verified. Fail-closed; no bypass |
+| F-4 | Low | `RuleConditionalTransferLightMultiToken` stores approvals under the caller-supplied `token` but consumes them under `msg.sender`. Behind a shared `RuleEngine` this strands token-keyed approvals and collapses per-token isolation |
+| F-2, F-3, F-5, F-7, F-8, F-9, F-10, F-14 | Info | Max-supply views panic on overflow; `approveAndTransferIfAllowed` is direct-binding-only; the wrapper does not interface-check child rules; `RuleMintAllowance.canTransfer` is not authoritative; multi-token `detectTransferRestriction` depends on `msg.sender`; `unbindToken` leaves stale state; documentation drift |
+
+Proofs live in [`test/ThreatModel/ThreatModelTests.t.sol`](../test/ThreatModel/ThreatModelTests.t.sol) (18 tests: 15 unit/integration, 3 fuzz).
+
+### Automated Analysis
+
+See the consolidated [Audit & Security-Analysis Overview](./security/audits/AUDIT_OVERVIEW.md) for the full index and triage. Latest tool outputs (including feedback documents) are in [`doc/security/audits/tools/v0.4.0/`](./security/audits/tools/v0.4.0/).
+
+#### Static analysis (v0.5.0)
+
+Re-run **2026-08-13** for the v0.5.0 release, at solc `0.8.36`. Full reports and per-finding triage in
+[`doc/security/audits/tools/v0.5.0/`](./security/audits/tools/v0.5.0/); consolidated view in
+[`AUDIT_OVERVIEW.md`](./security/audits/AUDIT_OVERVIEW.md).
+
+| Tool | High | Medium | Low | Info | Anything to fix? |
+|---|---|---|---|---|---|
+| [Slither](https://github.com/crytic/slither) 0.11.5 | 2 | 11 | 17 | 14 | **No** — [feedback](./security/audits/tools/v0.5.0/slither-report-feedback.md) |
+| [Aderyn](https://github.com/Cyfrin/aderyn) 0.6.5 | 0 | 0 | 9 categories (336 instances) | 0 | **No** — [feedback](./security/audits/tools/v0.5.0/aderyn-report-feedback.md) |
+
+**Nothing to fix.** Every increase over v0.4.0 is proportional to the three contracts this release adds; the two
+new Slither categories (`uninitialized-local`, `timestamp`) were each verified against the source and are a
+`try`/`catch` assignment pattern and the Proof-of-Reserve staleness check respectively. The 2026-08-13 re-run
+lowered both totals (Slither 46 → 43, Aderyn 333 → 315 instances) even as the contract count rose, because the
+`AddressSetBatchLib` refactor consolidated the duplicated batch loops and consumes their return values.
+
+Commands used for `v0.5.0` (mocks excluded):
+
+```bash
+slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
+ > doc/security/audits/tools/v0.5.0/slither-report.md
+aderyn -x mocks --output doc/security/audits/tools/v0.5.0/aderyn-report.md
+```
+
+> The Slither filter must list **`lib`**: this is a Foundry project, so omitting it pulls the whole vendored
+> dependency tree into scope and inflates the result count roughly four-fold with OpenZeppelin-internal findings.
+
+Commands used for `v0.4.0` (mocks excluded):
+
+```bash
+slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
+ > doc/security/audits/tools/v0.4.0/slither-report.md
+aderyn -x mocks --output doc/security/audits/tools/v0.4.0/aderyn-report.md
+```
+
+#### Aderyn (v0.4.0)
+
+Static analysis with [Aderyn](https://github.com/Cyfrin/aderyn) 0.6.5, re-run **2026-07-14** after the security remediation. Full report and feedback in [`doc/security/audits/tools/v0.4.0/`](./security/audits/tools/v0.4.0/). **No High/Medium issues; nothing to fix** — all 9 Low findings are by-design or false positives (see [feedback](./security/audits/tools/v0.4.0/aderyn-report-feedback.md)). The run initially reported 10: an `Unused Import` (dead `RuleTransferValidation` import in the two `RuleSpenderWhitelist` deployment files) was a genuine cosmetic defect and has been **fixed**.
+
+| ID | Title | Instances | Verdict |
+|---|---|---|---|
+| L-1 | Centralization Risk | 68 | By design (regulated token issuer model) |
+| L-2 | Unspecific Solidity Pragma | 63 | By design (`^0.8.20` library; project pins solc 0.8.36) |
+| L-3 | Address State Variable Set Without Checks | 1 | False positive — zero-check enforced at public `setSanctionListOracle` |
+| L-4 | PUSH0 Opcode | 64 | By design — project targets Prague EVM |
+| L-5 | Modifier Invoked Only Once | 2 | By design — template method pattern |
+| L-6 | Empty Block | 61 | By design — `_authorize*()` hooks + required interface no-ops |
+| L-7 | Loop Contains `require`/`revert` | 3 | **By design — recommendation rejected.** Batch adds revert on `address(0)` on purpose: skipping it made the emitted event name the sentinel as a set member |
+| L-8 | Costly operations inside loop | 7 | By design — `EnumerableSet` requires one `SSTORE` per element |
+| L-9 | Unchecked Return | 13 | Mixed — majority false positives; constructor `_grantRole` intentional |
+| — | Unused Import | 0 | **Fixed** during this run (was 2) |
+
+#### Slither (v0.4.0)
+
+Static analysis with [Slither](https://github.com/crytic/slither) 0.11.5, re-run **2026-07-14** after the security remediation (tally unchanged from the previous run). Full report and feedback in [`doc/security/audits/tools/v0.4.0/`](./security/audits/tools/v0.4.0/). **Nothing to fix** — the two High `arbitrary-send-erc20` hits are false positives (approval-gated, allowance-checked compliance flow); see [feedback](./security/audits/tools/v0.4.0/slither-report-feedback.md).
+
+| Category | Severity | Instances | Verdict |
+|---|---|---|---|
+| arbitrary-send-erc20 | High | 2 | False positive — `from` guarded by `onlyTransferApprover`, recorded approval, allowance check, bound token (light + multi-token) |
+| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before internal helper |
+| calls-loop | Low | 16 | By design — wrapper must query each child rule; child rules are read-only |
+| assembly | Informational | 2 | By design — memory-safe hash in `_transferHash` (light + multi-token) |
+| naming-convention | Informational | 2 | By design — parameter names match ERC-2980 spec |
+| unused-state | Informational | 8 | False positive — `RuleNFTAdapter` constants used in base dispatch (per-contract analysis limitation) |
+
+#### Aderyn (v0.3.0)
+
+Static analysis was performed with [Aderyn](https://github.com/Cyfrin/aderyn). The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.3.0/`](./security/audits/tools/v0.3.0/).
+
+| ID | Title | Instances | Verdict |
+|---|---|---|---|
+| L-1 | Centralization Risk | 46 | Acknowledged — by design (regulated token issuer model) |
+| L-2 | Unspecific Solidity Pragma | 54 | Acknowledged — intentional for a library |
+| L-3 | Address State Variable Set Without Checks | 1 | False positive — check enforced in public-facing function |
+| L-4 | PUSH0 Opcode | 54 | Acknowledged — project targets Prague EVM |
+| L-5 | Modifier Invoked Only Once | 2 | Acknowledged — template method pattern; inlining would break abstraction |
+| L-6 | Empty Block | 38 | Acknowledged — `_authorize*()` hooks use modifiers; intentional no-op implementations in required interface paths |
+| L-7 | Costly operations inside loop | 6 | Acknowledged — unavoidable (`EnumerableSet` requires one `SSTORE` per element) |
+| L-8 | Unchecked Return | 13 | Mixed — mostly false positives (`void` helpers or pre-checked single-item paths); constructor `_grantRole` intentionally ignored |
+
+No high-severity issues were reported.
+
+#### Slither (v0.3.0)
+
+Static analysis was performed with [Slither](https://github.com/crytic/slither). The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.3.0/`](./security/audits/tools/v0.3.0/).
+
+| Category | Severity | Instances | Verdict |
+|---|---|---|---|
+| arbitrary-send-erc20 | High | 1 | False positive — `from` is guarded by `onlyTransferApprover`, ERC-20 allowance check, and a pre-recorded approval |
+| unused-return | Medium | 6 | False positive — existence pre-checked at public layer before calling internal helper |
+| calls-loop | Low | 16 | Acknowledged — by design; wrapper must query each child rule; child rules are read-only |
+| assembly | Informational | 1 | Acknowledged — intentional gas optimisation in `_transferHash`; minimal and well-scoped |
+| naming-convention | Informational | 2 | Acknowledged — parameter names match ERC-2980 spec |
+| unindexed-event-address | Informational | 2 | Out of scope (both in `lib/RuleEngine`); `IAddressList` events previously fixed |
+| unused-state | Informational | 8 | False positive — `RuleNFTAdapter` constants used in base dispatch logic; Slither per-contract analysis limitation |
+
+#### Wake Arena (v0.2.0)
+
+AI-assisted static analysis was performed with [Wake Arena](https://getwake.io) by Ackee Blockchain Security. The full report and the project team's feedback are available in [`doc/security/audits/tools/v0.2.0/`](./security/audits/tools/v0.2.0/).
+
+*Ackee Blockchain Security, Wake Arena AI Report | CMTA: Rules, March 16, 2026 18:00 UTC.*
+
+| ID | Title | Severity | Confidence | Verdict |
+|---|---|---|---|---|
+| H-1 | ConditionalTransferLight approvals not scoped by token | High | High | Fixed — single-token binding enforced in `bindToken`; `RuleConditionalTransferLight_TokenAlreadyBound` error added |
+| M-1 | Incomplete `supportsInterface` breaks ERC-165 discovery | Medium | High | Fixed — pre-computed constants + `IERC7551Compliance` + full ERC-3643 `ICompliance` ID (`IERC3643ComplianceFull`, `0x3144991c`) added |
+| I-1 | RuleERC2980 docs omit frozen spender on `transferFrom` | Informational | High | Fixed (doc only) — README, `AGENTS.md`, and `CLAUDE.md` updated to document spender freeze path |
+| I-2 | `hasRole` override: admin implicitly passes all role checks | Informational | High | Fixed (doc only) — dedicated section added to README documenting intentional design and off-chain monitoring guidance |
+
+## Development
+
+Parts of this project were written with the help of AI coding assistants, principally **Claude Code**
+(Anthropic) and **Codex** (OpenAI).
+
+## Intellectual property
+
+The code is copyright (c) Capital Market and Technology Association, 2022-2026, and is released under [Mozilla Public License 2.0](https://github.com/CMTA/CMTAT/blob/master/LICENSE.md).
diff --git a/doc/coverage/lcov.info b/doc/coverage/lcov.info
index 787ebeea..4b819914 100644
--- a/doc/coverage/lcov.info
+++ b/doc/coverage/lcov.info
@@ -1,20 +1,708 @@
TN:
+SF:script/DeployCMTATWithBlacklist.s.sol
+DA:11,1
+FN:11,DeployCMTATWithBlacklist.deploy
+FNDA:1,DeployCMTATWithBlacklist.deploy
+DA:12,1
+DA:13,1
+DA:14,1
+DA:15,1
+DA:22,1
+DA:24,1
+DA:25,1
+DA:27,1
+DA:29,1
+BRDA:29,0,0,1
+DA:30,1
+DA:31,1
+DA:35,0
+FN:35,DeployCMTATWithBlacklist.run
+FNDA:0,DeployCMTATWithBlacklist.run
+DA:36,0
+DA:37,0
+DA:38,0
+FNF:2
+FNH:1
+LF:16
+LH:12
+BRF:1
+BRH:1
+end_of_record
+TN:
+SF:script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
+DA:27,18
+FN:27,DeployCMTATWithBlacklistAndSanctionsList.deploy
+FNDA:18,DeployCMTATWithBlacklistAndSanctionsList.deploy
+DA:36,18
+DA:37,18
+DA:38,18
+DA:39,18
+DA:46,18
+DA:49,18
+DA:52,18
+DA:53,18
+DA:57,18
+DA:60,18
+DA:61,18
+DA:64,18
+DA:67,18
+BRDA:67,0,0,18
+DA:68,18
+DA:69,18
+DA:70,18
+DA:71,18
+DA:75,0
+FN:75,DeployCMTATWithBlacklistAndSanctionsList.run
+FNDA:0,DeployCMTATWithBlacklistAndSanctionsList.run
+DA:84,0
+DA:87,0
+DA:89,0
+FNF:2
+FNH:1
+LF:22
+LH:18
+BRF:1
+BRH:1
+end_of_record
+TN:
+SF:script/DeployCMTATWithWhitelist.s.sol
+DA:11,1
+FN:11,DeployCMTATWithWhitelist.deploy
+FNDA:1,DeployCMTATWithWhitelist.deploy
+DA:15,1
+DA:16,1
+DA:17,1
+DA:18,1
+DA:25,1
+DA:27,1
+DA:28,1
+DA:30,1
+DA:32,1
+BRDA:32,0,0,1
+DA:33,1
+DA:34,1
+DA:38,0
+FN:38,DeployCMTATWithWhitelist.run
+FNDA:0,DeployCMTATWithWhitelist.run
+DA:39,0
+DA:40,0
+DA:41,0
+FNF:2
+FNH:1
+LF:16
+LH:12
+BRF:1
+BRH:1
+end_of_record
+TN:
+SF:src/mocks/AggregatorV3Mock.sol
+DA:24,601
+FN:24,AggregatorV3Mock.constructor
+FNDA:601,AggregatorV3Mock.constructor
+DA:25,601
+DA:26,601
+DA:27,601
+DA:28,601
+DA:39,264
+FN:39,AggregatorV3Mock.setAnswer
+FNDA:264,AggregatorV3Mock.setAnswer
+DA:40,264
+DA:41,264
+DA:42,264
+DA:49,1
+FN:49,AggregatorV3Mock.setUpdatedAt
+FNDA:1,AggregatorV3Mock.setUpdatedAt
+DA:50,1
+DA:57,7
+FN:57,AggregatorV3Mock.setDecimals
+FNDA:7,AggregatorV3Mock.setDecimals
+DA:58,7
+DA:65,2
+FN:65,AggregatorV3Mock.setRevertOnDecimals
+FNDA:2,AggregatorV3Mock.setRevertOnDecimals
+DA:66,2
+DA:73,2
+FN:73,AggregatorV3Mock.setRevertOnLatestRoundData
+FNDA:2,AggregatorV3Mock.setRevertOnLatestRoundData
+DA:74,2
+DA:80,1861
+FN:80,AggregatorV3Mock.decimals
+FNDA:1861,AggregatorV3Mock.decimals
+DA:81,1861
+BRDA:81,0,0,3
+BRDA:81,0,1,1858
+DA:82,1858
+DA:88,1
+FN:88,AggregatorV3Mock.description
+FNDA:1,AggregatorV3Mock.description
+DA:89,1
+DA:95,1
+FN:95,AggregatorV3Mock.version
+FNDA:1,AggregatorV3Mock.version
+DA:96,1
+DA:102,1
+FN:102,AggregatorV3Mock.getRoundData
+FNDA:1,AggregatorV3Mock.getRoundData
+DA:108,1
+DA:114,1247
+FN:114,AggregatorV3Mock.latestRoundData
+FNDA:1247,AggregatorV3Mock.latestRoundData
+DA:120,1247
+BRDA:120,1,0,2
+BRDA:120,1,1,1245
+DA:121,1245
+FNF:11
+FNH:11
+LF:29
+LH:29
+BRF:4
+BRH:4
+end_of_record
+TN:
+SF:src/mocks/ERC3643TokenMock.sol
+DA:67,27
+FN:67,ERC3643TokenMock.constructor
+FNDA:27,ERC3643TokenMock.constructor
+DA:68,27
+DA:69,27
+DA:73,25
+FN:73,ERC3643TokenMock.onlyAgent
+FNDA:25,ERC3643TokenMock.onlyAgent
+DA:74,25
+BRDA:74,0,0,-
+BRDA:74,0,1,6
+DA:86,0
+FN:86,ERC3643TokenMock.setIdentityRegistry
+FNDA:0,ERC3643TokenMock.setIdentityRegistry
+DA:87,0
+DA:97,13
+FN:97,ERC3643TokenMock.setCompliance
+FNDA:13,ERC3643TokenMock.setCompliance
+DA:98,13
+BRDA:98,1,0,-
+DA:99,0
+DA:101,13
+DA:102,13
+DA:110,0
+FN:110,ERC3643TokenMock.setAgent
+FNDA:0,ERC3643TokenMock.setAgent
+DA:111,0
+DA:122,7
+FN:122,ERC3643TokenMock.transfer
+FNDA:7,ERC3643TokenMock.transfer
+DA:123,7
+BRDA:123,2,0,-
+BRDA:123,2,1,7
+DA:124,7
+BRDA:124,3,0,3
+DA:125,3
+DA:126,3
+DA:127,3
+DA:129,4
+DA:140,4
+FN:140,ERC3643TokenMock.transferFrom
+FNDA:4,ERC3643TokenMock.transferFrom
+DA:141,4
+BRDA:141,4,0,-
+BRDA:141,4,1,4
+DA:142,4
+BRDA:142,5,0,2
+DA:143,2
+DA:144,2
+DA:145,2
+DA:147,2
+DA:158,4
+FN:158,ERC3643TokenMock.forcedTransfer
+FNDA:4,ERC3643TokenMock.forcedTransfer
+DA:159,6
+BRDA:159,6,0,-
+BRDA:159,6,1,6
+DA:163,6
+BRDA:163,7,0,5
+DA:164,5
+DA:165,5
+DA:166,4
+DA:168,1
+DA:177,25
+FN:177,ERC3643TokenMock.mint
+FNDA:25,ERC3643TokenMock.mint
+DA:178,25
+BRDA:178,8,0,2
+BRDA:178,8,1,23
+DA:179,23
+BRDA:179,9,0,1
+BRDA:179,9,1,22
+DA:180,22
+DA:181,22
+DA:182,22
+DA:183,22
+BRDA:183,10,0,10
+DA:184,10
+DA:194,3
+FN:194,ERC3643TokenMock.burn
+FNDA:3,ERC3643TokenMock.burn
+DA:195,3
+BRDA:195,11,0,-
+BRDA:195,11,1,3
+DA:196,3
+DA:197,3
+DA:198,3
+DA:199,3
+BRDA:199,12,0,2
+DA:200,2
+DA:219,4
+FN:219,ERC3643TokenMock.recoveryAddress
+FNDA:4,ERC3643TokenMock.recoveryAddress
+DA:224,4
+BRDA:224,13,0,-
+BRDA:224,13,1,4
+DA:226,4
+DA:227,4
+BRDA:227,14,0,3
+DA:228,3
+DA:229,3
+DA:232,2
+DA:233,2
+DA:234,2
+DA:235,2
+DA:237,1
+DA:251,31
+FN:251,ERC3643TokenMock._canTransfer
+FNDA:31,ERC3643TokenMock._canTransfer
+DA:252,31
+BRDA:252,15,0,15
+DA:253,15
+DA:255,16
+DA:264,10
+FN:264,ERC3643TokenMock._complianceTransferred
+FNDA:10,ERC3643TokenMock._complianceTransferred
+DA:265,10
+BRDA:265,16,0,4
+DA:266,4
+DA:276,10
+FN:276,ERC3643TokenMock._transfer
+FNDA:10,ERC3643TokenMock._transfer
+DA:277,10
+DA:278,10
+DA:279,10
+FNF:14
+FNH:12
+LF:72
+LH:67
+BRF:25
+BRH:18
+end_of_record
+TN:
+SF:src/mocks/IAddressListInterfaceIdHelper.sol
+DA:88,1
+FN:88,IAddressListInterfaceIdHelper.getIAddressListInterfaceId
+FNDA:1,IAddressListInterfaceIdHelper.getIAddressListInterfaceId
+DA:89,1
+DA:96,0
+FN:96,IAddressListInterfaceIdHelper.getIAddressListAllFunctionsInterfaceId
+FNDA:0,IAddressListInterfaceIdHelper.getIAddressListAllFunctionsInterfaceId
+DA:97,0
+DA:104,0
+FN:104,IAddressListInterfaceIdHelper.getAddressListInterfaceIdConstant
+FNDA:0,IAddressListInterfaceIdHelper.getAddressListInterfaceIdConstant
+DA:105,0
+DA:112,1
+FN:112,IAddressListInterfaceIdHelper.getIIdentityRegistryContainsInterfaceId
+FNDA:1,IAddressListInterfaceIdHelper.getIIdentityRegistryContainsInterfaceId
+DA:113,1
+FNF:4
+FNH:2
+LF:8
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/IdentityRegistryMock.sol
+DA:25,35
+FN:25,IdentityRegistryMock.setVerified
+FNDA:35,IdentityRegistryMock.setVerified
+DA:26,35
+DA:34,60
+FN:34,IdentityRegistryMock.isVerified
+FNDA:60,IdentityRegistryMock.isVerified
+DA:35,60
+FNF:2
+FNH:2
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/MockERC20TransferFromFalse.sol
+DA:25,1
+FN:25,MockERC20TransferFromFalse.setAllowance
+FNDA:1,MockERC20TransferFromFalse.setAllowance
+DA:26,1
+DA:35,1
+FN:35,MockERC20TransferFromFalse.allowance
+FNDA:1,MockERC20TransferFromFalse.allowance
+DA:36,1
+DA:43,1
+FN:43,MockERC20TransferFromFalse.transferFrom
+FNDA:1,MockERC20TransferFromFalse.transferFrom
+DA:44,1
+FNF:3
+FNH:3
+LF:6
+LH:6
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/MockERC20WithTransferContext.sol
+DA:37,19
+FN:37,MockERC20WithTransferContext.setRule
+FNDA:19,MockERC20WithTransferContext.setRule
+DA:38,19
+DA:46,19
+FN:46,MockERC20WithTransferContext.mint
+FNDA:19,MockERC20WithTransferContext.mint
+DA:47,19
+DA:58,3
+FN:58,MockERC20WithTransferContext.transferWithContext
+FNDA:3,MockERC20WithTransferContext.transferWithContext
+DA:62,3
+DA:63,2
+BRDA:63,0,0,2
+BRDA:63,0,1,1
+DA:64,2
+DA:66,1
+DA:68,3
+DA:80,3
+FN:80,MockERC20WithTransferContext.transferFromWithContext
+FNDA:3,MockERC20WithTransferContext.transferFromWithContext
+DA:84,3
+DA:85,3
+DA:86,3
+DA:88,2
+BRDA:88,1,0,2
+BRDA:88,1,1,1
+DA:89,2
+DA:91,1
+DA:93,3
+DA:103,3
+FN:103,MockERC20WithTransferContext.transfer
+FNDA:3,MockERC20WithTransferContext.transfer
+DA:104,3
+DA:105,3
+DA:106,2
+DA:112,5
+FN:112,MockERC20WithTransferContext.transferFrom
+FNDA:5,MockERC20WithTransferContext.transferFrom
+DA:113,5
+DA:114,5
+DA:115,5
+DA:116,4
+DA:131,12
+FN:131,MockERC20WithTransferContext._notifyFungible
+FNDA:12,MockERC20WithTransferContext._notifyFungible
+DA:132,12
+BRDA:132,2,0,12
+DA:133,12
+DA:136,12
+DA:144,12
+DA:156,2
+FN:156,MockERC20WithTransferContext._notifyMultiToken
+FNDA:2,MockERC20WithTransferContext._notifyMultiToken
+DA:157,2
+BRDA:157,3,0,2
+DA:158,2
+DA:161,2
+DA:170,2
+FNF:8
+FNH:8
+LF:37
+LH:37
+BRF:6
+BRH:6
+end_of_record
+TN:
+SF:src/mocks/MockERC721WithTransferContext.sol
+DA:37,3
+FN:37,MockERC721WithTransferContext.setRule
+FNDA:3,MockERC721WithTransferContext.setRule
+DA:38,3
+DA:46,3
+FN:46,MockERC721WithTransferContext.mint
+FNDA:3,MockERC721WithTransferContext.mint
+DA:47,3
+DA:57,4
+FN:57,MockERC721WithTransferContext.transferFrom
+FNDA:4,MockERC721WithTransferContext.transferFrom
+DA:58,4
+DA:59,4
+DA:60,4
+DA:75,4
+FN:75,MockERC721WithTransferContext._notifyRule
+FNDA:4,MockERC721WithTransferContext._notifyRule
+DA:76,4
+BRDA:76,0,0,4
+DA:77,4
+DA:80,4
+DA:89,4
+FNF:4
+FNH:4
+LF:13
+LH:13
+BRF:1
+BRH:1
+end_of_record
+TN:
+SF:src/mocks/OnchainIdMock.sol
+DA:25,3
+FN:25,OnchainIdMock.addWalletKey
+FNDA:3,OnchainIdMock.addWalletKey
+DA:26,3
+DA:32,4
+FN:32,OnchainIdMock.keyHasPurpose
+FNDA:4,OnchainIdMock.keyHasPurpose
+DA:33,4
+FNF:2
+FNH:2
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/SanctionListOracle.sol
+DA:24,24
+FN:24,SanctionListOracle.addToSanctionsList
+FNDA:24,SanctionListOracle.addToSanctionsList
+DA:25,24
+DA:32,1
+FN:32,SanctionListOracle.removeFromSanctionsList
+FNDA:1,SanctionListOracle.removeFromSanctionsList
+DA:33,1
+DA:41,267
+FN:41,SanctionListOracle.isSanctioned
+FNDA:267,SanctionListOracle.isSanctioned
+DA:42,267
+FNF:3
+FNH:3
+LF:6
+LH:6
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/TotalSupplyDecimalsMock.sol
+DA:18,593
+FN:18,TotalSupplyDecimalsMock.constructor
+FNDA:593,TotalSupplyDecimalsMock.constructor
+DA:19,593
+DA:30,554
+FN:30,TotalSupplyDecimalsMock.setTotalSupply
+FNDA:554,TotalSupplyDecimalsMock.setTotalSupply
+DA:31,554
+DA:38,2
+FN:38,TotalSupplyDecimalsMock.setRevertOnTotalSupply
+FNDA:2,TotalSupplyDecimalsMock.setRevertOnTotalSupply
+DA:39,2
+DA:46,1033
+FN:46,TotalSupplyDecimalsMock.totalSupply
+FNDA:1033,TotalSupplyDecimalsMock.totalSupply
+DA:47,1033
+BRDA:47,0,0,4
+BRDA:47,0,1,1029
+DA:48,1029
+DA:55,594
+FN:55,TotalSupplyDecimalsMock.decimals
+FNDA:594,TotalSupplyDecimalsMock.decimals
+DA:56,594
+FNF:5
+FNH:5
+LF:11
+LH:11
+BRF:2
+BRH:2
+end_of_record
+TN:
+SF:src/mocks/TotalSupplyMock.sol
+DA:22,781
+FN:22,TotalSupplyMock.setTotalSupply
+FNDA:781,TotalSupplyMock.setTotalSupply
+DA:23,781
+DA:30,1333
+FN:30,TotalSupplyMock.totalSupply
+FNDA:1333,TotalSupplyMock.totalSupply
+DA:31,1333
+FNF:2
+FNH:2
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/harness/DeploymentCoverageHarnesses.sol
+DA:38,1
+FN:38,RuleBlacklistHarness.exposedMsgDataLength.0
+FNDA:1,RuleBlacklistHarness.exposedMsgDataLength.0
+DA:39,1
+DA:70,1
+FN:70,RuleWhitelistHarness.exposedMsgDataLength.1
+FNDA:1,RuleWhitelistHarness.exposedMsgDataLength.1
+DA:71,1
+DA:102,1
+FN:102,RuleWhitelistWrapperHarness.exposedMsgDataLength.2
+FNDA:1,RuleWhitelistWrapperHarness.exposedMsgDataLength.2
+DA:103,1
+DA:133,1
+FN:133,RuleERC2980Harness.exposedMsgDataLength.3
+FNDA:1,RuleERC2980Harness.exposedMsgDataLength.3
+DA:134,1
+DA:164,1
+FN:164,RuleSanctionsListHarness.exposedMsgDataLength.4
+FNDA:1,RuleSanctionsListHarness.exposedMsgDataLength.4
+DA:165,1
+DA:192,1
+FN:192,RuleBlacklistOwnable2StepHarness.exposedMsgDataLength.5
+FNDA:1,RuleBlacklistOwnable2StepHarness.exposedMsgDataLength.5
+DA:193,1
+DA:224,1
+FN:224,RuleWhitelistOwnable2StepHarness.exposedMsgDataLength.6
+FNDA:1,RuleWhitelistOwnable2StepHarness.exposedMsgDataLength.6
+DA:225,1
+DA:256,1
+FN:256,RuleWhitelistWrapperOwnable2StepHarness.exposedMsgDataLength.7
+FNDA:1,RuleWhitelistWrapperOwnable2StepHarness.exposedMsgDataLength.7
+DA:257,1
+DA:287,1
+FN:287,RuleERC2980Ownable2StepHarness.exposedMsgDataLength.8
+FNDA:1,RuleERC2980Ownable2StepHarness.exposedMsgDataLength.8
+DA:288,1
+FNF:9
+FNH:9
+LF:18
+LH:18
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/harness/RuleReceiverWhitelistHarnesses.sol
+DA:32,1
+FN:32,RuleReceiverWhitelistHarness.exposedMsgSender.0
+FNDA:1,RuleReceiverWhitelistHarness.exposedMsgSender.0
+DA:33,1
+DA:40,1
+FN:40,RuleReceiverWhitelistHarness.exposedMsgData.0
+FNDA:1,RuleReceiverWhitelistHarness.exposedMsgData.0
+DA:41,1
+DA:48,1
+FN:48,RuleReceiverWhitelistHarness.exposedContextSuffixLength.0
+FNDA:1,RuleReceiverWhitelistHarness.exposedContextSuffixLength.0
+DA:49,1
+DA:78,1
+FN:78,RuleReceiverWhitelistOwnable2StepHarness.exposedMsgSender.1
+FNDA:1,RuleReceiverWhitelistOwnable2StepHarness.exposedMsgSender.1
+DA:79,1
+DA:86,1
+FN:86,RuleReceiverWhitelistOwnable2StepHarness.exposedMsgData.1
+FNDA:1,RuleReceiverWhitelistOwnable2StepHarness.exposedMsgData.1
+DA:87,1
+DA:94,1
+FN:94,RuleReceiverWhitelistOwnable2StepHarness.exposedContextSuffixLength.1
+FNDA:1,RuleReceiverWhitelistOwnable2StepHarness.exposedContextSuffixLength.1
+DA:95,1
+FNF:6
+FNH:6
+LF:12
+LH:12
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/harness/RuleSanctionsListOwnable2StepHarness.sol
+DA:33,1
+FN:33,RuleSanctionsListOwnable2StepHarness.exposedMsgSender
+FNDA:1,RuleSanctionsListOwnable2StepHarness.exposedMsgSender
+DA:34,1
+DA:41,1
+FN:41,RuleSanctionsListOwnable2StepHarness.exposedMsgData
+FNDA:1,RuleSanctionsListOwnable2StepHarness.exposedMsgData
+DA:42,1
+DA:49,1
+FN:49,RuleSanctionsListOwnable2StepHarness.exposedContextSuffixLength
+FNDA:1,RuleSanctionsListOwnable2StepHarness.exposedContextSuffixLength
+DA:50,1
+FNF:3
+FNH:3
+LF:6
+LH:6
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/harness/RuleSpenderWhitelistHarnesses.sol
+DA:30,1
+FN:30,RuleSpenderWhitelistHarness.exposedMsgSender.0
+FNDA:1,RuleSpenderWhitelistHarness.exposedMsgSender.0
+DA:31,1
+DA:38,1
+FN:38,RuleSpenderWhitelistHarness.exposedMsgData.0
+FNDA:1,RuleSpenderWhitelistHarness.exposedMsgData.0
+DA:39,1
+DA:46,1
+FN:46,RuleSpenderWhitelistHarness.exposedContextSuffixLength.0
+FNDA:1,RuleSpenderWhitelistHarness.exposedContextSuffixLength.0
+DA:47,1
+DA:76,1
+FN:76,RuleSpenderWhitelistOwnable2StepHarness.exposedMsgSender.1
+FNDA:1,RuleSpenderWhitelistOwnable2StepHarness.exposedMsgSender.1
+DA:77,1
+DA:84,1
+FN:84,RuleSpenderWhitelistOwnable2StepHarness.exposedMsgData.1
+FNDA:1,RuleSpenderWhitelistOwnable2StepHarness.exposedMsgData.1
+DA:85,1
+DA:92,1
+FN:92,RuleSpenderWhitelistOwnable2StepHarness.exposedContextSuffixLength.1
+FNDA:1,RuleSpenderWhitelistOwnable2StepHarness.exposedContextSuffixLength.1
+DA:93,1
+FNF:6
+FNH:6
+LF:12
+LH:12
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/mocks/harness/RuleWhitelistWrapperHarnessInternal.sol
+DA:36,1
+FN:36,RuleWhitelistWrapperHarnessInternal.exposedTransferredSpenderInternal
+FNDA:1,RuleWhitelistWrapperHarnessInternal.exposedTransferredSpenderInternal
+DA:37,1
+FNF:1
+FNH:1
+LF:2
+LH:2
+BRF:0
+BRH:0
+end_of_record
+TN:
SF:src/modules/AccessControlModuleStandalone.sol
-DA:30,1622
+DA:30,2308
FN:30,AccessControlModuleStandalone.constructor
-FNDA:1622,AccessControlModuleStandalone.constructor
-DA:31,1622
+FNDA:2308,AccessControlModuleStandalone.constructor
+DA:31,2308
BRDA:31,0,0,7
-BRDA:31,0,1,1615
-DA:35,1615
-DA:46,1746
+BRDA:31,0,1,2301
+DA:35,2301
+DA:46,2432
FN:46,AccessControlModuleStandalone.hasRole
-FNDA:1746,AccessControlModuleStandalone.hasRole
-DA:55,21064
-BRDA:55,1,0,4032
-BRDA:55,1,1,17032
-DA:56,4032
-DA:58,17032
+FNDA:2432,AccessControlModuleStandalone.hasRole
+DA:55,22060
+BRDA:55,1,0,4187
+BRDA:55,1,1,17873
+DA:56,4187
+DA:58,17873
FNF:2
FNH:2
LF:7
@@ -24,12 +712,12 @@ BRH:4
end_of_record
TN:
SF:src/modules/Ownable2StepERC165Module.sol
-DA:17,68
+DA:17,78
FN:17,Ownable2StepERC165Module.supportsInterface
-FNDA:68,Ownable2StepERC165Module.supportsInterface
-DA:18,68
-DA:19,57
-DA:20,46
+FNDA:78,Ownable2StepERC165Module.supportsInterface
+DA:18,78
+DA:19,66
+DA:20,54
FNF:1
FNH:1
LF:4
@@ -51,6 +739,65 @@ BRF:0
BRH:0
end_of_record
TN:
+SF:src/registry/IdentityRegistryWhitelist.sol
+DA:34,85
+FN:34,IdentityRegistryWhitelist._authorizeIdentityRegistrar
+FNDA:85,IdentityRegistryWhitelist._authorizeIdentityRegistrar
+FNF:1
+FNH:1
+LF:1
+LH:1
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/registry/abstract/IdentityRegistryWhitelistBase.sol
+DA:69,78
+FN:69,IdentityRegistryWhitelistBase.registerIdentity
+FNDA:78,IdentityRegistryWhitelistBase.registerIdentity
+DA:81,76
+BRDA:81,0,0,1
+BRDA:81,0,1,75
+DA:82,75
+BRDA:82,1,0,1
+BRDA:82,1,1,74
+DA:83,74
+DA:84,74
+DA:91,7
+FN:91,IdentityRegistryWhitelistBase.deleteIdentity
+FNDA:7,IdentityRegistryWhitelistBase.deleteIdentity
+DA:92,6
+BRDA:92,2,0,1
+BRDA:92,2,1,5
+DA:93,5
+DA:94,5
+DA:103,3
+FN:103,IdentityRegistryWhitelistBase.registeredIdentityCount
+FNDA:3,IdentityRegistryWhitelistBase.registeredIdentityCount
+DA:104,3
+DA:117,54
+FN:117,IdentityRegistryWhitelistBase.isVerified
+FNDA:54,IdentityRegistryWhitelistBase.isVerified
+DA:118,54
+DA:127,7
+FN:127,IdentityRegistryWhitelistBase.investorCountry
+FNDA:7,IdentityRegistryWhitelistBase.investorCountry
+DA:136,7
+DA:143,78
+FN:143,IdentityRegistryWhitelistBase.onlyIdentityRegistrar
+FNDA:78,IdentityRegistryWhitelistBase.onlyIdentityRegistrar
+DA:144,78
+DA:152,0
+FN:152,IdentityRegistryWhitelistBase._authorizeIdentityRegistrar
+FNDA:0,IdentityRegistryWhitelistBase._authorizeIdentityRegistrar
+FNF:7
+FNH:6
+LF:18
+LH:17
+BRF:6
+BRH:6
+end_of_record
+TN:
SF:src/rules/operation/RuleConditionalTransferLight.sol
DA:41,48
FN:41,RuleConditionalTransferLight.supportsInterface
@@ -63,9 +810,9 @@ DA:52,29
DA:62,58
FN:62,RuleConditionalTransferLight._onlyComplianceManager
FNDA:58,RuleConditionalTransferLight._onlyComplianceManager
-DA:67,7607
+DA:67,7593
FN:67,RuleConditionalTransferLight._authorizeTransferApproval
-FNDA:7607,RuleConditionalTransferLight._authorizeTransferApproval
+FNDA:7593,RuleConditionalTransferLight._authorizeTransferApproval
DA:72,3
FN:72,RuleConditionalTransferLight._authorizeComplianceBindingChange
FNDA:3,RuleConditionalTransferLight._authorizeComplianceBindingChange
@@ -160,9 +907,9 @@ DA:50,21
DA:60,312
FN:60,RuleMintAllowance._onlyComplianceManager
FNDA:312,RuleMintAllowance._onlyComplianceManager
-DA:65,10070
+DA:65,10087
FN:65,RuleMintAllowance._authorizeSetMintAllowance
-FNDA:10070,RuleMintAllowance._authorizeSetMintAllowance
+FNDA:10087,RuleMintAllowance._authorizeSetMintAllowance
DA:70,4
FN:70,RuleMintAllowance._authorizeComplianceBindingChange
FNDA:4,RuleMintAllowance._authorizeComplianceBindingChange
@@ -212,22 +959,22 @@ DA:40,3
FN:40,RuleConditionalTransferLightApprovalBase.transferred
FNDA:3,RuleConditionalTransferLightApprovalBase.transferred
DA:41,3
-DA:54,6210
+DA:54,6211
FN:54,RuleConditionalTransferLightApprovalBase.approveTransfer
-FNDA:6210,RuleConditionalTransferLightApprovalBase.approveTransfer
-DA:55,6213
-DA:56,6213
-DA:57,6213
-DA:66,1386
+FNDA:6211,RuleConditionalTransferLightApprovalBase.approveTransfer
+DA:55,6214
+DA:56,6214
+DA:57,6214
+DA:66,1371
FN:66,RuleConditionalTransferLightApprovalBase.cancelTransferApproval
-FNDA:1386,RuleConditionalTransferLightApprovalBase.cancelTransferApproval
-DA:67,1385
-DA:68,1385
-DA:69,1385
+FNDA:1371,RuleConditionalTransferLightApprovalBase.cancelTransferApproval
+DA:67,1370
+DA:68,1370
+DA:69,1370
BRDA:69,0,0,1
-BRDA:69,0,1,1384
-DA:70,1384
-DA:71,1384
+BRDA:69,0,1,1369
+DA:70,1369
+DA:71,1369
DA:86,4
FN:86,RuleConditionalTransferLightApprovalBase.resetApproval
FNDA:4,RuleConditionalTransferLightApprovalBase.resetApproval
@@ -247,27 +994,27 @@ DA:119,3
FN:119,RuleConditionalTransferLightApprovalBase._transferredFromContext
FNDA:3,RuleConditionalTransferLightApprovalBase._transferredFromContext
DA:120,3
-DA:130,6353
+DA:130,6258
FN:130,RuleConditionalTransferLightApprovalBase._transferred
-FNDA:6353,RuleConditionalTransferLightApprovalBase._transferred
-DA:131,6353
-BRDA:131,2,0,6353
-DA:132,6353
-DA:134,2307
-DA:135,2307
-DA:137,2307
+FNDA:6258,RuleConditionalTransferLightApprovalBase._transferred
+DA:131,6258
+BRDA:131,2,0,6258
+DA:132,6258
+DA:134,2217
+DA:135,2217
+DA:137,2217
BRDA:137,3,0,5
-BRDA:137,3,1,2302
-DA:139,2302
-DA:140,2302
-DA:150,19124
+BRDA:137,3,1,2212
+DA:139,2212
+DA:140,2212
+DA:150,19020
FN:150,RuleConditionalTransferLightApprovalBase._transferHash
-FNDA:19124,RuleConditionalTransferLightApprovalBase._transferHash
-DA:153,19124
-DA:154,19124
-DA:155,19124
-DA:156,19124
-DA:157,19124
+FNDA:19020,RuleConditionalTransferLightApprovalBase._transferHash
+DA:153,19020
+DA:154,19020
+DA:155,19020
+DA:156,19020
+DA:157,19020
DA:164,0
FN:164,RuleConditionalTransferLightApprovalBase._authorizeTransferApproval
FNDA:0,RuleConditionalTransferLightApprovalBase._authorizeTransferApproval
@@ -316,10 +1063,10 @@ BRDA:124,2,0,1
BRDA:124,2,1,3
DA:126,3
DA:127,2
-DA:133,6347
+DA:133,6252
FN:133,RuleConditionalTransferLightBase.transferred.0
-FNDA:6347,RuleConditionalTransferLightBase.transferred.0
-DA:138,6342
+FNDA:6252,RuleConditionalTransferLightBase.transferred.0
+DA:138,6247
DA:144,7
FN:144,RuleConditionalTransferLightBase.transferred.1
FNDA:7,RuleConditionalTransferLightBase.transferred.1
@@ -354,7 +1101,7 @@ DA:223,1
DA:231,8
FN:231,RuleConditionalTransferLightBase.isTransferExecutor
FNDA:8,RuleConditionalTransferLightBase.isTransferExecutor
-DA:232,6365
+DA:232,6270
DA:238,7
FN:238,RuleConditionalTransferLightBase.detectTransferRestriction
FNDA:7,RuleConditionalTransferLightBase.detectTransferRestriction
@@ -378,12 +1125,12 @@ DA:287,1
FN:287,RuleConditionalTransferLightBase.canTransferFrom
FNDA:1,RuleConditionalTransferLightBase.canTransferFrom
DA:293,1
-DA:306,6357
+DA:306,6262
FN:306,RuleConditionalTransferLightBase._authorizeTransferExecution
-FNDA:6357,RuleConditionalTransferLightBase._authorizeTransferExecution
-DA:307,6357
+FNDA:6262,RuleConditionalTransferLightBase._authorizeTransferExecution
+DA:307,6262
BRDA:307,9,0,6
-BRDA:307,9,1,6351
+BRDA:307,9,1,6256
FNF:16
FNH:16
LF:52
@@ -492,58 +1239,58 @@ DA:306,2
FN:306,RuleConditionalTransferLightMultiTokenBase.canTransferFrom
FNDA:2,RuleConditionalTransferLightMultiTokenBase.canTransferFrom
DA:312,2
-DA:328,538
-FN:328,RuleConditionalTransferLightMultiTokenBase._detectTransferRestrictionForToken
-FNDA:538,RuleConditionalTransferLightMultiTokenBase._detectTransferRestrictionForToken
-DA:334,538
-BRDA:334,4,0,2
-DA:335,2
-DA:338,536
-BRDA:338,5,0,7
-DA:339,7
-DA:342,529
-BRDA:342,6,0,519
-DA:343,519
-DA:346,10
-DA:360,38
-FN:360,RuleConditionalTransferLightMultiTokenBase._authorizeComplianceBindingChange
+DA:327,38
+FN:327,RuleConditionalTransferLightMultiTokenBase._authorizeComplianceBindingChange
FNDA:38,RuleConditionalTransferLightMultiTokenBase._authorizeComplianceBindingChange
-DA:361,38
-DA:371,25
-FN:371,RuleConditionalTransferLightMultiTokenBase._approveTransfer
+DA:328,38
+DA:338,25
+FN:338,RuleConditionalTransferLightMultiTokenBase._approveTransfer
FNDA:25,RuleConditionalTransferLightMultiTokenBase._approveTransfer
-DA:372,25
-BRDA:372,7,0,2
-BRDA:372,7,1,23
-DA:373,23
-DA:374,23
-DA:375,23
-DA:385,2
-FN:385,RuleConditionalTransferLightMultiTokenBase._cancelTransferApproval
+DA:339,25
+BRDA:339,4,0,2
+BRDA:339,4,1,23
+DA:340,23
+DA:341,23
+DA:342,23
+DA:352,2
+FN:352,RuleConditionalTransferLightMultiTokenBase._cancelTransferApproval
FNDA:2,RuleConditionalTransferLightMultiTokenBase._cancelTransferApproval
-DA:386,2
-BRDA:386,8,0,-
-BRDA:386,8,1,2
-DA:387,2
-DA:388,2
-DA:390,2
-BRDA:390,9,0,1
-BRDA:390,9,1,1
-DA:392,1
-DA:393,1
-DA:404,15
-FN:404,RuleConditionalTransferLightMultiTokenBase._transferred
+DA:353,2
+BRDA:353,5,0,-
+BRDA:353,5,1,2
+DA:354,2
+DA:355,2
+DA:357,2
+BRDA:357,6,0,1
+BRDA:357,6,1,1
+DA:359,1
+DA:360,1
+DA:371,15
+FN:371,RuleConditionalTransferLightMultiTokenBase._transferred
FNDA:15,RuleConditionalTransferLightMultiTokenBase._transferred
-DA:405,15
-BRDA:405,10,0,15
-DA:406,15
-DA:409,9
-DA:410,9
-DA:412,9
-BRDA:412,11,0,3
-BRDA:412,11,1,6
-DA:414,6
-DA:415,6
+DA:372,15
+BRDA:372,7,0,15
+DA:373,15
+DA:376,9
+DA:377,9
+DA:379,9
+BRDA:379,8,0,3
+BRDA:379,8,1,6
+DA:381,6
+DA:382,6
+DA:397,538
+FN:397,RuleConditionalTransferLightMultiTokenBase._detectTransferRestrictionForToken
+FNDA:538,RuleConditionalTransferLightMultiTokenBase._detectTransferRestrictionForToken
+DA:403,538
+BRDA:403,9,0,2
+DA:404,2
+DA:407,536
+BRDA:407,10,0,7
+DA:408,7
+DA:411,529
+BRDA:411,11,0,519
+DA:412,519
+DA:415,10
DA:421,13
FN:421,RuleConditionalTransferLightMultiTokenBase._authorizeTransferExecution
FNDA:13,RuleConditionalTransferLightMultiTokenBase._authorizeTransferExecution
@@ -595,16 +1342,16 @@ FNDA:3241,RuleMintAllowanceBase.increaseMintAllowance
DA:96,3240
DA:97,3240
DA:98,3240
-DA:107,3078
+DA:107,3095
FN:107,RuleMintAllowanceBase.decreaseMintAllowance
-FNDA:3078,RuleMintAllowanceBase.decreaseMintAllowance
-DA:108,3077
-DA:109,3077
+FNDA:3095,RuleMintAllowanceBase.decreaseMintAllowance
+DA:108,3094
+DA:109,3094
BRDA:109,0,0,1
-BRDA:109,0,1,3076
-DA:110,3076
-DA:111,3076
-DA:112,3076
+BRDA:109,0,1,3093
+DA:110,3093
+DA:111,3093
+DA:112,3093
DA:124,4
FN:124,RuleMintAllowanceBase.clearMintAllowances
FNDA:4,RuleMintAllowanceBase.clearMintAllowances
@@ -659,11 +1406,11 @@ BRDA:271,3,0,6848
DA:272,6848
DA:274,3577
DA:275,3577
-BRDA:275,4,0,252
-BRDA:275,4,1,3325
-DA:276,3325
-DA:277,3325
-DA:278,3325
+BRDA:275,4,0,258
+BRDA:275,4,1,3319
+DA:276,3319
+DA:277,3319
+DA:278,3319
DA:286,3758
FN:286,RuleMintAllowanceBase._setMintAllowance
FNDA:3758,RuleMintAllowanceBase._setMintAllowance
@@ -688,79 +1435,79 @@ BRH:9
end_of_record
TN:
SF:src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol
-DA:40,278
+DA:40,279
FN:40,RuleAddressSet.onlyAddressListAdd
-FNDA:278,RuleAddressSet.onlyAddressListAdd
-DA:41,278
-DA:45,12
+FNDA:279,RuleAddressSet.onlyAddressListAdd
+DA:41,279
+DA:45,17
FN:45,RuleAddressSet.onlyAddressListRemove
-FNDA:12,RuleAddressSet.onlyAddressListRemove
-DA:46,12
-DA:61,278
+FNDA:17,RuleAddressSet.onlyAddressListRemove
+DA:46,17
+DA:61,279
FN:61,RuleAddressSet.addAddresses
-FNDA:278,RuleAddressSet.addAddresses
-DA:62,277
-DA:63,275
-DA:73,260
+FNDA:279,RuleAddressSet.addAddresses
+DA:62,278
+DA:63,276
+DA:73,261
FN:73,RuleAddressSet.removeAddresses
-FNDA:260,RuleAddressSet.removeAddresses
-DA:74,259
-DA:75,259
-DA:85,132
+FNDA:261,RuleAddressSet.removeAddresses
+DA:74,260
+DA:75,260
+DA:85,182
FN:85,RuleAddressSet.addAddress
-FNDA:132,RuleAddressSet.addAddress
-DA:86,127
-BRDA:86,0,0,2
-BRDA:86,0,1,125
-DA:87,125
+FNDA:182,RuleAddressSet.addAddress
+DA:86,175
+BRDA:86,0,0,3
+BRDA:86,0,1,172
+DA:87,172
BRDA:87,1,0,1
-BRDA:87,1,1,124
-DA:88,124
-DA:89,124
-DA:99,12
+BRDA:87,1,1,171
+DA:88,171
+DA:89,171
+DA:99,17
FN:99,RuleAddressSet.removeAddress
-FNDA:12,RuleAddressSet.removeAddress
-DA:100,7
+FNDA:17,RuleAddressSet.removeAddress
+DA:100,10
BRDA:100,2,0,1
-BRDA:100,2,1,6
-DA:101,6
-DA:102,6
-DA:109,543
+BRDA:100,2,1,9
+DA:101,9
+DA:102,9
+DA:109,545
FN:109,RuleAddressSet.listedAddressCount
-FNDA:543,RuleAddressSet.listedAddressCount
-DA:110,543
+FNDA:545,RuleAddressSet.listedAddressCount
+DA:110,545
DA:118,4
FN:118,RuleAddressSet.contains
FNDA:4,RuleAddressSet.contains
DA:119,4
-DA:127,79
+DA:127,86
FN:127,RuleAddressSet.isAddressListed
-FNDA:79,RuleAddressSet.isAddressListed
-DA:128,577
-DA:136,155
+FNDA:86,RuleAddressSet.isAddressListed
+DA:128,623
+DA:136,157
FN:136,RuleAddressSet.areAddressesListed
-FNDA:155,RuleAddressSet.areAddressesListed
-DA:137,155
-DA:138,155
-DA:139,345
+FNDA:157,RuleAddressSet.areAddressesListed
+DA:137,157
+DA:138,157
+DA:139,349
DA:150,0
FN:150,RuleAddressSet._authorizeAddressListAdd
FNDA:0,RuleAddressSet._authorizeAddressListAdd
DA:155,0
FN:155,RuleAddressSet._authorizeAddressListRemove
FNDA:0,RuleAddressSet._authorizeAddressListRemove
-DA:160,994
+DA:160,1127
FN:160,RuleAddressSet._msgSender
-FNDA:994,RuleAddressSet._msgSender
-DA:161,994
-DA:167,6
+FNDA:1127,RuleAddressSet._msgSender
+DA:161,1127
+DA:167,8
FN:167,RuleAddressSet._msgData
-FNDA:6,RuleAddressSet._msgData
-DA:168,6
-DA:174,1002
+FNDA:8,RuleAddressSet._msgData
+DA:168,8
+DA:174,1139
FN:174,RuleAddressSet._contextSuffixLength
-FNDA:1002,RuleAddressSet._contextSuffixLength
-DA:175,1002
+FNDA:1139,RuleAddressSet._contextSuffixLength
+DA:175,1139
FNF:15
FNH:13
LF:37
@@ -770,43 +1517,43 @@ BRH:6
end_of_record
TN:
SF:src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol
-DA:41,277
+DA:41,278
FN:41,RuleAddressSetInternal._addAddresses
-FNDA:277,RuleAddressSetInternal._addAddresses
-DA:42,277
-DA:49,813
+FNDA:278,RuleAddressSetInternal._addAddresses
+DA:42,278
+DA:49,815
BRDA:49,0,0,2
-BRDA:49,0,1,811
-DA:50,811
-BRDA:50,1,0,551
+BRDA:49,0,1,813
+DA:50,813
+BRDA:50,1,0,553
BRDA:50,1,1,260
-DA:51,551
+DA:51,553
DA:53,260
-DA:67,259
+DA:67,260
FN:67,RuleAddressSetInternal._removeAddresses
-FNDA:259,RuleAddressSetInternal._removeAddresses
-DA:71,259
-DA:72,775
-BRDA:72,2,0,518
+FNDA:260,RuleAddressSetInternal._removeAddresses
+DA:71,260
+DA:72,777
+BRDA:72,2,0,520
BRDA:72,2,1,257
-DA:73,518
+DA:73,520
DA:75,257
-DA:84,124
+DA:84,245
FN:84,RuleAddressSetInternal._addAddress
-FNDA:124,RuleAddressSetInternal._addAddress
-DA:85,124
-DA:92,6
+FNDA:245,RuleAddressSetInternal._addAddress
+DA:85,245
+DA:92,14
FN:92,RuleAddressSetInternal._removeAddress
-FNDA:6,RuleAddressSetInternal._removeAddress
-DA:93,6
-DA:100,543
+FNDA:14,RuleAddressSetInternal._removeAddress
+DA:93,14
+DA:100,548
FN:100,RuleAddressSetInternal._listedAddressCount
-FNDA:543,RuleAddressSetInternal._listedAddressCount
-DA:101,543
-DA:109,1093
+FNDA:548,RuleAddressSetInternal._listedAddressCount
+DA:101,548
+DA:109,1345
FN:109,RuleAddressSetInternal._isAddressListed
-FNDA:1093,RuleAddressSetInternal._isAddressListed
-DA:110,1093
+FNDA:1345,RuleAddressSetInternal._isAddressListed
+DA:110,1345
FNF:6
FNH:6
LF:19
@@ -973,6 +1720,221 @@ BRF:14
BRH:14
end_of_record
TN:
+SF:src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol
+DA:74,600
+FN:74,RuleChainlinkPoRBase.constructor
+FNDA:600,RuleChainlinkPoRBase.constructor
+DA:80,600
+DA:81,598
+DA:82,596
+DA:94,8
+FN:94,RuleChainlinkPoRBase.canReturnTransferRestrictionCode
+FNDA:8,RuleChainlinkPoRBase.canReturnTransferRestrictionCode
+DA:95,8
+DA:96,4
+DA:109,8
+FN:109,RuleChainlinkPoRBase.setReservesFeed
+FNDA:8,RuleChainlinkPoRBase.setReservesFeed
+DA:110,6
+DA:119,12
+FN:119,RuleChainlinkPoRBase.setTokenMetadata
+FNDA:12,RuleChainlinkPoRBase.setTokenMetadata
+DA:120,10
+DA:127,5
+FN:127,RuleChainlinkPoRBase.setMaxStalenessSeconds
+FNDA:5,RuleChainlinkPoRBase.setMaxStalenessSeconds
+DA:128,3
+DA:138,4
+FN:138,RuleChainlinkPoRBase.feedDecimals
+FNDA:4,RuleChainlinkPoRBase.feedDecimals
+DA:139,4
+DA:152,657
+FN:152,RuleChainlinkPoRBase.maxBackedSupply
+FNDA:657,RuleChainlinkPoRBase.maxBackedSupply
+DA:153,657
+DA:159,4
+FN:159,RuleChainlinkPoRBase.transferred.0
+FNDA:4,RuleChainlinkPoRBase.transferred.0
+DA:160,4
+DA:166,13
+FN:166,RuleChainlinkPoRBase.transferred.1
+FNDA:13,RuleChainlinkPoRBase.transferred.1
+DA:167,13
+DA:173,8
+FN:173,RuleChainlinkPoRBase.messageForTransferRestriction
+FNDA:8,RuleChainlinkPoRBase.messageForTransferRestriction
+DA:179,8
+BRDA:179,0,0,2
+BRDA:179,0,1,1
+DA:180,2
+DA:181,6
+BRDA:181,1,0,2
+BRDA:181,1,1,1
+DA:182,2
+DA:183,4
+BRDA:183,2,0,2
+BRDA:183,2,1,1
+DA:184,2
+DA:185,2
+BRDA:185,3,0,1
+DA:186,1
+DA:188,1
+DA:195,8
+FN:195,RuleChainlinkPoRBase.onlyChainlinkPoRManager
+FNDA:8,RuleChainlinkPoRBase.onlyChainlinkPoRManager
+DA:196,8
+DA:204,0
+FN:204,RuleChainlinkPoRBase._authorizeChainlinkPoRManager
+FNDA:0,RuleChainlinkPoRBase._authorizeChainlinkPoRManager
+DA:218,606
+FN:218,RuleChainlinkPoRBase._setReservesFeed
+FNDA:606,RuleChainlinkPoRBase._setReservesFeed
+DA:219,606
+DA:220,606
+BRDA:220,4,0,1
+BRDA:220,4,1,605
+DA:221,605
+BRDA:221,5,0,1
+BRDA:221,5,1,604
+DA:222,604
+DA:223,604
+BRDA:223,6,0,604
+DA:224,603
+DA:225,1
+BRDA:225,6,1,1
+DA:226,1
+DA:228,603
+BRDA:228,7,0,1
+BRDA:228,7,1,602
+DA:229,602
+DA:230,602
+DA:241,608
+FN:241,RuleChainlinkPoRBase._setTokenMetadata
+FNDA:608,RuleChainlinkPoRBase._setTokenMetadata
+DA:242,608
+BRDA:242,8,0,2
+BRDA:242,8,1,606
+DA:246,606
+BRDA:246,9,0,2
+BRDA:246,9,1,604
+DA:247,604
+BRDA:247,10,0,1
+BRDA:247,10,1,603
+DA:248,603
+BRDA:248,11,0,603
+DA:249,602
+BRDA:249,12,0,1
+BRDA:249,12,1,601
+DA:258,602
+BRDA:258,13,0,602
+DA:259,1
+BRDA:259,13,1,1
+DA:260,1
+DA:262,601
+DA:263,601
+DA:264,601
+DA:271,599
+FN:271,RuleChainlinkPoRBase._setMaxStalenessSeconds
+FNDA:599,RuleChainlinkPoRBase._setMaxStalenessSeconds
+DA:272,599
+DA:273,599
+DA:282,1252
+FN:282,RuleChainlinkPoRBase._maxBackedSupply
+FNDA:1252,RuleChainlinkPoRBase._maxBackedSupply
+DA:283,1252
+DA:286,1252
+DA:287,1252
+BRDA:287,14,0,1252
+DA:288,1250
+DA:289,2
+BRDA:289,14,1,2
+DA:290,2
+DA:294,1250
+BRDA:294,15,0,3
+DA:295,3
+DA:297,1247
+BRDA:297,16,0,1247
+DA:299,1245
+BRDA:299,17,0,135
+DA:300,135
+DA:302,1110
+DA:303,1110
+BRDA:303,18,0,3
+DA:304,3
+DA:308,1107
+DA:309,1107
+DA:310,2
+BRDA:310,16,1,2
+DA:311,2
+DA:325,453
+FN:325,RuleChainlinkPoRBase._currentSupply
+FNDA:453,RuleChainlinkPoRBase._currentSupply
+DA:326,453
+DA:327,453
+BRDA:327,19,0,453
+DA:328,449
+DA:329,4
+BRDA:329,19,1,4
+DA:330,4
+DA:343,1107
+FN:343,RuleChainlinkPoRBase._scaleReserve
+FNDA:1107,RuleChainlinkPoRBase._scaleReserve
+DA:344,1107
+DA:345,1107
+BRDA:345,20,0,58
+DA:346,58
+DA:348,1049
+BRDA:348,21,0,639
+DA:350,639
+DA:351,639
+BRDA:351,22,0,32
+DA:352,32
+DA:354,607
+DA:357,410
+DA:363,601
+FN:363,RuleChainlinkPoRBase._detectTransferRestriction
+FNDA:601,RuleChainlinkPoRBase._detectTransferRestriction
+DA:375,601
+BRDA:375,23,0,6
+DA:376,6
+DA:378,595
+DA:379,595
+BRDA:379,24,0,142
+DA:380,142
+DA:382,453
+DA:383,453
+BRDA:383,25,0,4
+DA:384,4
+DA:388,449
+BRDA:388,26,0,224
+DA:389,224
+DA:391,225
+DA:397,18
+FN:397,RuleChainlinkPoRBase._detectTransferRestrictionFrom
+FNDA:18,RuleChainlinkPoRBase._detectTransferRestrictionFrom
+DA:403,18
+DA:412,4
+FN:412,RuleChainlinkPoRBase._transferred
+FNDA:4,RuleChainlinkPoRBase._transferred
+DA:413,4
+DA:414,4
+BRDA:414,27,0,2
+BRDA:414,27,1,2
+DA:427,13
+FN:427,RuleChainlinkPoRBase._transferredFrom
+FNDA:13,RuleChainlinkPoRBase._transferredFrom
+DA:428,13
+DA:429,13
+BRDA:429,28,0,5
+BRDA:429,28,1,8
+FNF:22
+FNH:21
+LF:116
+LH:115
+BRF:46
+BRH:46
+end_of_record
+TN:
SF:src/rules/validation/abstract/base/RuleERC2980Base.sol
DA:64,75
FN:64,RuleERC2980Base.constructor
@@ -1355,87 +2317,172 @@ BRH:19
end_of_record
TN:
SF:src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol
-DA:34,542
-FN:34,RuleMaxTotalSupplyBase.constructor
-FNDA:542,RuleMaxTotalSupplyBase.constructor
-DA:35,542
-BRDA:35,0,0,1
-BRDA:35,0,1,541
-DA:36,541
-DA:37,541
-DA:49,2
-FN:49,RuleMaxTotalSupplyBase.canReturnTransferRestrictionCode
-FNDA:2,RuleMaxTotalSupplyBase.canReturnTransferRestrictionCode
-DA:50,2
-DA:61,260
-FN:61,RuleMaxTotalSupplyBase.setMaxTotalSupply
+DA:36,550
+FN:36,RuleMaxTotalSupplyBase.constructor
+FNDA:550,RuleMaxTotalSupplyBase.constructor
+DA:37,550
+DA:38,547
+DA:39,547
+DA:51,3
+FN:51,RuleMaxTotalSupplyBase.canReturnTransferRestrictionCode
+FNDA:3,RuleMaxTotalSupplyBase.canReturnTransferRestrictionCode
+DA:52,3
+DA:63,260
+FN:63,RuleMaxTotalSupplyBase.setMaxTotalSupply
FNDA:260,RuleMaxTotalSupplyBase.setMaxTotalSupply
-DA:62,258
-DA:63,258
-DA:70,4
-FN:70,RuleMaxTotalSupplyBase.setTokenContract
-FNDA:4,RuleMaxTotalSupplyBase.setTokenContract
-DA:71,2
-BRDA:71,1,0,1
-BRDA:71,1,1,1
-DA:72,1
-DA:73,1
-DA:79,2
-FN:79,RuleMaxTotalSupplyBase.transferred.0
-FNDA:2,RuleMaxTotalSupplyBase.transferred.0
-DA:80,2
-DA:86,2
-FN:86,RuleMaxTotalSupplyBase.transferred.1
+DA:64,258
+DA:65,258
+DA:72,8
+FN:72,RuleMaxTotalSupplyBase.setTokenContract
+FNDA:8,RuleMaxTotalSupplyBase.setTokenContract
+DA:73,6
+DA:74,3
+DA:75,3
+DA:81,3
+FN:81,RuleMaxTotalSupplyBase.transferred.0
+FNDA:3,RuleMaxTotalSupplyBase.transferred.0
+DA:82,3
+DA:88,2
+FN:88,RuleMaxTotalSupplyBase.transferred.1
FNDA:2,RuleMaxTotalSupplyBase.transferred.1
+DA:89,2
+DA:95,3
+FN:95,RuleMaxTotalSupplyBase.messageForTransferRestriction
+FNDA:3,RuleMaxTotalSupplyBase.messageForTransferRestriction
+DA:101,3
+BRDA:101,0,0,1
+BRDA:101,0,1,1
+DA:102,1
+DA:103,2
+BRDA:103,1,0,1
+DA:104,1
+DA:106,1
+DA:113,260
+FN:113,RuleMaxTotalSupplyBase.onlyMaxTotalSupplyManager
+FNDA:260,RuleMaxTotalSupplyBase.onlyMaxTotalSupplyManager
+DA:114,260
+DA:121,0
+FN:121,RuleMaxTotalSupplyBase._authorizeMaxTotalSupplyManager
+FNDA:0,RuleMaxTotalSupplyBase._authorizeMaxTotalSupplyManager
+DA:135,556
+FN:135,RuleMaxTotalSupplyBase._validateTokenContract
+FNDA:556,RuleMaxTotalSupplyBase._validateTokenContract
+DA:136,556
+BRDA:136,2,0,2
+BRDA:136,2,1,554
+DA:137,554
+BRDA:137,3,0,2
+BRDA:137,3,1,552
+DA:138,552
+BRDA:138,4,0,552
+DA:139,2
+BRDA:139,4,1,2
+DA:140,2
+DA:157,788
+FN:157,RuleMaxTotalSupplyBase._currentSupply
+FNDA:788,RuleMaxTotalSupplyBase._currentSupply
+DA:158,788
+DA:159,788
+BRDA:159,5,0,788
+DA:160,784
+DA:161,4
+BRDA:161,5,1,4
+DA:162,4
+DA:169,793
+FN:169,RuleMaxTotalSupplyBase._detectTransferRestriction
+FNDA:793,RuleMaxTotalSupplyBase._detectTransferRestriction
+DA:180,793
+BRDA:180,6,0,788
+DA:181,788
+DA:182,788
+BRDA:182,7,0,4
+DA:183,4
+DA:187,784
+BRDA:187,8,0,452
+DA:188,452
+DA:191,337
+DA:197,4
+FN:197,RuleMaxTotalSupplyBase._detectTransferRestrictionFrom
+FNDA:4,RuleMaxTotalSupplyBase._detectTransferRestrictionFrom
+DA:203,4
+DA:212,3
+FN:212,RuleMaxTotalSupplyBase._transferred
+FNDA:3,RuleMaxTotalSupplyBase._transferred
+DA:213,3
+DA:214,3
+BRDA:214,9,0,2
+BRDA:214,9,1,1
+DA:227,2
+FN:227,RuleMaxTotalSupplyBase._transferredFrom
+FNDA:2,RuleMaxTotalSupplyBase._transferredFrom
+DA:228,2
+DA:229,2
+BRDA:229,10,0,1
+BRDA:229,10,1,1
+FNF:15
+FNH:14
+LF:54
+LH:53
+BRF:18
+BRH:18
+end_of_record
+TN:
+SF:src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol
+DA:68,2
+FN:68,RuleReceiverWhitelistBase.canReturnTransferRestrictionCode
+FNDA:2,RuleReceiverWhitelistBase.canReturnTransferRestrictionCode
+DA:69,2
+DA:79,4
+FN:79,RuleReceiverWhitelistBase.transferred.0
+FNDA:4,RuleReceiverWhitelistBase.transferred.0
+DA:80,4
+DA:86,2
+FN:86,RuleReceiverWhitelistBase.transferred.1
+FNDA:2,RuleReceiverWhitelistBase.transferred.1
DA:87,2
DA:93,2
-FN:93,RuleMaxTotalSupplyBase.messageForTransferRestriction
-FNDA:2,RuleMaxTotalSupplyBase.messageForTransferRestriction
+FN:93,RuleReceiverWhitelistBase.messageForTransferRestriction
+FNDA:2,RuleReceiverWhitelistBase.messageForTransferRestriction
DA:99,2
-BRDA:99,2,0,1
+BRDA:99,0,0,1
DA:100,1
DA:102,1
-DA:109,260
-FN:109,RuleMaxTotalSupplyBase.onlyMaxTotalSupplyManager
-FNDA:260,RuleMaxTotalSupplyBase.onlyMaxTotalSupplyManager
-DA:110,260
-DA:117,0
-FN:117,RuleMaxTotalSupplyBase._authorizeMaxTotalSupplyManager
-FNDA:0,RuleMaxTotalSupplyBase._authorizeMaxTotalSupplyManager
-DA:126,787
-FN:126,RuleMaxTotalSupplyBase._detectTransferRestriction
-FNDA:787,RuleMaxTotalSupplyBase._detectTransferRestriction
-DA:137,787
-BRDA:137,3,0,784
-DA:138,784
-DA:141,784
-BRDA:141,4,0,458
-DA:142,458
-DA:145,329
-DA:151,3
-FN:151,RuleMaxTotalSupplyBase._detectTransferRestrictionFrom
-FNDA:3,RuleMaxTotalSupplyBase._detectTransferRestrictionFrom
-DA:157,3
-DA:166,2
-FN:166,RuleMaxTotalSupplyBase._transferred
-FNDA:2,RuleMaxTotalSupplyBase._transferred
-DA:167,2
-DA:168,2
-BRDA:168,5,0,1
-BRDA:168,5,1,1
-DA:181,2
-FN:181,RuleMaxTotalSupplyBase._transferredFrom
-FNDA:2,RuleMaxTotalSupplyBase._transferredFrom
-DA:182,2
-DA:183,2
-BRDA:183,6,0,1
-BRDA:183,6,1,1
-FNF:13
-FNH:12
-LF:38
-LH:37
-BRF:11
-BRH:11
+DA:108,7
+FN:108,RuleReceiverWhitelistBase.supportsInterface
+FNDA:7,RuleReceiverWhitelistBase.supportsInterface
+DA:111,7
+DA:112,6
+DA:125,21
+FN:125,RuleReceiverWhitelistBase._detectTransferRestriction
+FNDA:21,RuleReceiverWhitelistBase._detectTransferRestriction
+DA:128,21
+BRDA:128,1,0,6
+DA:129,6
+DA:131,15
+DA:143,7
+FN:143,RuleReceiverWhitelistBase._detectTransferRestrictionFrom
+FNDA:7,RuleReceiverWhitelistBase._detectTransferRestrictionFrom
+DA:150,7
+DA:159,4
+FN:159,RuleReceiverWhitelistBase._transferred
+FNDA:4,RuleReceiverWhitelistBase._transferred
+DA:160,4
+DA:161,4
+BRDA:161,2,0,1
+BRDA:161,2,1,3
+DA:174,2
+FN:174,RuleReceiverWhitelistBase._transferredFrom
+FNDA:2,RuleReceiverWhitelistBase._transferredFrom
+DA:175,2
+DA:176,2
+BRDA:176,3,0,1
+BRDA:176,3,1,1
+FNF:9
+FNH:9
+LF:25
+LH:25
+BRF:6
+BRH:6
end_of_record
TN:
SF:src/rules/validation/abstract/base/RuleSanctionsListBase.sol
@@ -1595,11 +2642,11 @@ BRH:4
end_of_record
TN:
SF:src/rules/validation/abstract/base/RuleWhitelistBase.sol
-DA:32,188
+DA:32,201
FN:32,RuleWhitelistBase.constructor
-FNDA:188,RuleWhitelistBase.constructor
-DA:35,188
-DA:36,188
+FNDA:201,RuleWhitelistBase.constructor
+DA:35,201
+DA:36,201
DA:48,3
FN:48,RuleWhitelistBase.setCheckSpender
FNDA:3,RuleWhitelistBase.setCheckSpender
@@ -1609,11 +2656,11 @@ DA:56,6
FN:56,RuleWhitelistBase.isVerified
FNDA:6,RuleWhitelistBase.isVerified
DA:63,6
-DA:69,35
+DA:69,61
FN:69,RuleWhitelistBase.supportsInterface
-FNDA:35,RuleWhitelistBase.supportsInterface
-DA:72,35
-DA:73,33
+FNDA:61,RuleWhitelistBase.supportsInterface
+DA:72,61
+DA:73,59
DA:80,3
FN:80,RuleWhitelistBase.onlyCheckSpenderManager
FNDA:3,RuleWhitelistBase.onlyCheckSpenderManager
@@ -1625,22 +2672,22 @@ DA:94,2
DA:101,0
FN:101,RuleWhitelistBase._authorizeCheckSpenderManager
FNDA:0,RuleWhitelistBase._authorizeCheckSpenderManager
-DA:109,100
+DA:109,132
FN:109,RuleWhitelistBase._detectTransferRestriction
-FNDA:100,RuleWhitelistBase._detectTransferRestriction
-DA:120,100
-DA:121,100
-DA:124,100
-DA:125,100
-BRDA:125,0,0,7
-DA:126,7
-DA:131,93
+FNDA:132,RuleWhitelistBase._detectTransferRestriction
+DA:120,132
+DA:121,132
+DA:124,132
+DA:125,132
+BRDA:125,0,0,9
+DA:126,9
+DA:131,123
BRDA:131,1,0,30
DA:132,30
-DA:134,63
-BRDA:134,2,0,11
-DA:135,11
-DA:137,52
+DA:134,93
+BRDA:134,2,0,15
+DA:135,15
+DA:137,78
DA:148,38
FN:148,RuleWhitelistBase._detectTransferRestrictionFrom
FNDA:38,RuleWhitelistBase._detectTransferRestrictionFrom
@@ -1682,13 +2729,13 @@ DA:84,7
DA:85,7
DA:86,7
DA:87,7
-DA:100,0
-FN:100,RuleWhitelistWrapperBase._authorizeCheckSpenderManager
-FNDA:0,RuleWhitelistWrapperBase._authorizeCheckSpenderManager
-DA:106,3
-FN:106,RuleWhitelistWrapperBase._setCheckSpender
+DA:98,3
+FN:98,RuleWhitelistWrapperBase._setCheckSpender
FNDA:3,RuleWhitelistWrapperBase._setCheckSpender
-DA:107,3
+DA:99,3
+DA:108,0
+FN:108,RuleWhitelistWrapperBase._authorizeCheckSpenderManager
+FNDA:0,RuleWhitelistWrapperBase._authorizeCheckSpenderManager
DA:117,66
FN:117,RuleWhitelistWrapperBase._detectTransferRestriction
FNDA:66,RuleWhitelistWrapperBase._detectTransferRestriction
@@ -1861,29 +2908,29 @@ BRH:4
end_of_record
TN:
SF:src/rules/validation/abstract/core/RuleTransferValidation.sol
-DA:36,923
+DA:36,1526
FN:36,RuleTransferValidation.detectTransferRestriction
-FNDA:923,RuleTransferValidation.detectTransferRestriction
-DA:43,923
-DA:49,58
+FNDA:1526,RuleTransferValidation.detectTransferRestriction
+DA:43,1526
+DA:49,66
FN:49,RuleTransferValidation.detectTransferRestrictionFrom
-FNDA:58,RuleTransferValidation.detectTransferRestrictionFrom
-DA:56,58
-DA:67,37
+FNDA:66,RuleTransferValidation.detectTransferRestrictionFrom
+DA:56,66
+DA:67,43
FN:67,RuleTransferValidation.canTransfer
-FNDA:37,RuleTransferValidation.canTransfer
-DA:73,37
-DA:79,30
+FNDA:43,RuleTransferValidation.canTransfer
+DA:73,43
+DA:79,33
FN:79,RuleTransferValidation.canTransferFrom
-FNDA:30,RuleTransferValidation.canTransferFrom
-DA:86,30
-DA:95,230
+FNDA:33,RuleTransferValidation.canTransferFrom
+DA:86,33
+DA:95,280
FN:95,RuleTransferValidation.supportsInterface
-FNDA:230,RuleTransferValidation.supportsInterface
-DA:96,230
-DA:97,229
-DA:98,228
-DA:99,133
+FNDA:280,RuleTransferValidation.supportsInterface
+DA:96,280
+DA:97,277
+DA:98,274
+DA:99,157
DA:113,0
FN:113,RuleTransferValidation._detectTransferRestriction
FNDA:0,RuleTransferValidation._detectTransferRestriction
@@ -1899,10 +2946,10 @@ BRH:0
end_of_record
TN:
SF:src/rules/validation/abstract/core/RuleWhitelistShared.sol
-DA:46,13
+DA:46,32
FN:46,RuleWhitelistShared.onlyMintBurnManager
-FNDA:13,RuleWhitelistShared.onlyMintBurnManager
-DA:47,13
+FNDA:32,RuleWhitelistShared.onlyMintBurnManager
+DA:47,32
DA:62,10
FN:62,RuleWhitelistShared.canReturnTransferRestrictionCode
FNDA:10,RuleWhitelistShared.canReturnTransferRestrictionCode
@@ -1910,75 +2957,75 @@ DA:63,10
DA:64,5
DA:65,2
DA:66,2
-DA:73,13
-FN:73,RuleWhitelistShared.setAllowMint
-FNDA:13,RuleWhitelistShared.setAllowMint
-DA:74,10
-DA:75,10
-DA:82,7
-FN:82,RuleWhitelistShared.setAllowBurn
-FNDA:7,RuleWhitelistShared.setAllowBurn
-DA:83,5
-DA:84,5
-DA:94,19
-FN:94,RuleWhitelistShared.messageForTransferRestriction
+DA:76,19
+FN:76,RuleWhitelistShared.messageForTransferRestriction
FNDA:19,RuleWhitelistShared.messageForTransferRestriction
-DA:100,19
-BRDA:100,0,0,6
-BRDA:100,0,1,2
-DA:101,6
-DA:102,13
-BRDA:102,1,0,4
-BRDA:102,1,1,2
-DA:103,4
-DA:104,9
-BRDA:104,2,0,2
-BRDA:104,2,1,2
-DA:105,2
-DA:106,7
-BRDA:106,3,0,3
-BRDA:106,3,1,2
-DA:107,3
-DA:108,4
-BRDA:108,4,0,2
-BRDA:108,4,1,2
-DA:109,2
-DA:111,2
-DA:130,19
+DA:82,19
+BRDA:82,0,0,6
+BRDA:82,0,1,2
+DA:83,6
+DA:84,13
+BRDA:84,1,0,4
+BRDA:84,1,1,2
+DA:85,4
+DA:86,9
+BRDA:86,2,0,2
+BRDA:86,2,1,2
+DA:87,2
+DA:88,7
+BRDA:88,3,0,3
+BRDA:88,3,1,2
+DA:89,3
+DA:90,4
+BRDA:90,4,0,2
+BRDA:90,4,1,2
+DA:91,2
+DA:93,2
+DA:105,32
+FN:105,RuleWhitelistShared.setAllowMint
+FNDA:32,RuleWhitelistShared.setAllowMint
+DA:106,29
+DA:107,29
+DA:114,8
+FN:114,RuleWhitelistShared.setAllowBurn
+FNDA:8,RuleWhitelistShared.setAllowBurn
+DA:115,6
+DA:116,6
+DA:130,35
FN:130,RuleWhitelistShared.transferred.0
-FNDA:19,RuleWhitelistShared.transferred.0
-DA:131,19
+FNDA:35,RuleWhitelistShared.transferred.0
+DA:131,35
DA:145,13
FN:145,RuleWhitelistShared.transferred.1
FNDA:13,RuleWhitelistShared.transferred.1
DA:146,13
-DA:158,245
+DA:158,258
FN:158,RuleWhitelistShared._setAllowMintBurn
-FNDA:245,RuleWhitelistShared._setAllowMintBurn
-DA:159,245
-DA:160,245
-DA:161,245
-DA:162,245
-DA:172,166
+FNDA:258,RuleWhitelistShared._setAllowMintBurn
+DA:159,258
+DA:160,258
+DA:161,258
+DA:162,258
+DA:172,198
FN:172,RuleWhitelistShared._detectMintBurnRestriction
-FNDA:166,RuleWhitelistShared._detectMintBurnRestriction
-DA:173,166
-BRDA:173,5,0,8
-DA:174,8
-DA:176,158
-BRDA:176,6,0,3
-DA:177,3
-DA:179,155
+FNDA:198,RuleWhitelistShared._detectMintBurnRestriction
+DA:173,198
+BRDA:173,5,0,9
+DA:174,9
+DA:176,189
+BRDA:176,6,0,4
+DA:177,4
+DA:179,185
DA:185,0
FN:185,RuleWhitelistShared._authorizeMintBurnManager
FNDA:0,RuleWhitelistShared._authorizeMintBurnManager
-DA:190,40
+DA:190,56
FN:190,RuleWhitelistShared._transferred
-FNDA:40,RuleWhitelistShared._transferred
-DA:191,40
-DA:192,40
-BRDA:192,7,0,22
-BRDA:192,7,1,18
+FNDA:56,RuleWhitelistShared._transferred
+DA:191,56
+DA:192,56
+BRDA:192,7,0,24
+BRDA:192,7,1,32
DA:201,32
FN:201,RuleWhitelistShared._transferredFrom
FNDA:32,RuleWhitelistShared._transferredFrom
@@ -2058,13 +3105,47 @@ BRF:0
BRH:0
end_of_record
TN:
+SF:src/rules/validation/deployment/RuleChainlinkPoR.sol
+DA:47,21
+FN:47,RuleChainlinkPoR.supportsInterface
+FNDA:21,RuleChainlinkPoR.supportsInterface
+DA:54,21
+DA:55,14
+DA:65,19
+FN:65,RuleChainlinkPoR._authorizeChainlinkPoRManager
+FNDA:19,RuleChainlinkPoR._authorizeChainlinkPoRManager
+FNF:2
+FNH:2
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol
+DA:44,5
+FN:44,RuleChainlinkPoROwnable2Step.supportsInterface
+FNDA:5,RuleChainlinkPoROwnable2Step.supportsInterface
+DA:51,5
+DA:52,4
+DA:62,6
+FN:62,RuleChainlinkPoROwnable2Step._authorizeChainlinkPoRManager
+FNDA:6,RuleChainlinkPoROwnable2Step._authorizeChainlinkPoRManager
+FNF:2
+FNH:2
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
SF:src/rules/validation/deployment/RuleERC2980.sol
DA:56,1
FN:56,RuleERC2980.supportsInterface
FNDA:1,RuleERC2980.supportsInterface
DA:63,1
-DA:76,5
-FN:76,RuleERC2980._authorizeMintBurnManager
+DA:73,5
+FN:73,RuleERC2980._authorizeMintBurnManager
FNDA:5,RuleERC2980._authorizeMintBurnManager
DA:78,48
FN:78,RuleERC2980._authorizeWhitelistAdd
@@ -2103,8 +3184,8 @@ DA:39,5
FN:39,RuleERC2980Ownable2Step.supportsInterface
FNDA:5,RuleERC2980Ownable2Step.supportsInterface
DA:46,5
-DA:59,3
-FN:59,RuleERC2980Ownable2Step._authorizeMintBurnManager
+DA:56,3
+FN:56,RuleERC2980Ownable2Step._authorizeMintBurnManager
FNDA:3,RuleERC2980Ownable2Step._authorizeMintBurnManager
DA:61,7
FN:61,RuleERC2980Ownable2Step._authorizeWhitelistAdd
@@ -2139,13 +3220,13 @@ BRH:0
end_of_record
TN:
SF:src/rules/validation/deployment/RuleIdentityRegistry.sol
-DA:38,27
-FN:38,RuleIdentityRegistry.supportsInterface
-FNDA:27,RuleIdentityRegistry.supportsInterface
DA:45,27
-DA:46,18
-DA:56,12
-FN:56,RuleIdentityRegistry._authorizeIdentityRegistryManager
+FN:45,RuleIdentityRegistry.supportsInterface
+FNDA:27,RuleIdentityRegistry.supportsInterface
+DA:52,27
+DA:53,18
+DA:63,12
+FN:63,RuleIdentityRegistry._authorizeIdentityRegistryManager
FNDA:12,RuleIdentityRegistry._authorizeIdentityRegistryManager
FNF:2
FNH:2
@@ -2156,13 +3237,13 @@ BRH:0
end_of_record
TN:
SF:src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol
-DA:38,5
-FN:38,RuleIdentityRegistryOwnable2Step.supportsInterface
-FNDA:5,RuleIdentityRegistryOwnable2Step.supportsInterface
DA:45,5
-DA:46,2
-DA:56,4
-FN:56,RuleIdentityRegistryOwnable2Step._authorizeIdentityRegistryManager
+FN:45,RuleIdentityRegistryOwnable2Step.supportsInterface
+FNDA:5,RuleIdentityRegistryOwnable2Step.supportsInterface
+DA:52,5
+DA:53,2
+DA:63,4
+FN:63,RuleIdentityRegistryOwnable2Step._authorizeIdentityRegistryManager
FNDA:4,RuleIdentityRegistryOwnable2Step._authorizeIdentityRegistryManager
FNF:2
FNH:2
@@ -2178,9 +3259,9 @@ FN:37,RuleMaxTotalSupply.supportsInterface
FNDA:19,RuleMaxTotalSupply.supportsInterface
DA:44,19
DA:45,13
-DA:55,260
+DA:55,264
FN:55,RuleMaxTotalSupply._authorizeMaxTotalSupplyManager
-FNDA:260,RuleMaxTotalSupply._authorizeMaxTotalSupplyManager
+FNDA:264,RuleMaxTotalSupply._authorizeMaxTotalSupplyManager
FNF:2
FNH:2
LF:4
@@ -2206,6 +3287,70 @@ BRF:0
BRH:0
end_of_record
TN:
+SF:src/rules/validation/deployment/RuleReceiverWhitelist.sol
+DA:38,6
+FN:38,RuleReceiverWhitelist.supportsInterface
+FNDA:6,RuleReceiverWhitelist.supportsInterface
+DA:45,6
+DA:46,5
+DA:56,22
+FN:56,RuleReceiverWhitelist._authorizeAddressListAdd
+FNDA:22,RuleReceiverWhitelist._authorizeAddressListAdd
+DA:61,3
+FN:61,RuleReceiverWhitelist._authorizeAddressListRemove
+FNDA:3,RuleReceiverWhitelist._authorizeAddressListRemove
+DA:71,62
+FN:71,RuleReceiverWhitelist._msgSender
+FNDA:62,RuleReceiverWhitelist._msgSender
+DA:72,62
+DA:79,1
+FN:79,RuleReceiverWhitelist._msgData
+FNDA:1,RuleReceiverWhitelist._msgData
+DA:80,1
+DA:87,64
+FN:87,RuleReceiverWhitelist._contextSuffixLength
+FNDA:64,RuleReceiverWhitelist._contextSuffixLength
+DA:88,64
+FNF:6
+FNH:6
+LF:11
+LH:11
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol
+DA:39,5
+FN:39,RuleReceiverWhitelistOwnable2Step.supportsInterface
+FNDA:5,RuleReceiverWhitelistOwnable2Step.supportsInterface
+DA:46,5
+DA:47,2
+DA:57,2
+FN:57,RuleReceiverWhitelistOwnable2Step._authorizeAddressListAdd
+FNDA:2,RuleReceiverWhitelistOwnable2Step._authorizeAddressListAdd
+DA:62,2
+FN:62,RuleReceiverWhitelistOwnable2Step._authorizeAddressListRemove
+FNDA:2,RuleReceiverWhitelistOwnable2Step._authorizeAddressListRemove
+DA:72,10
+FN:72,RuleReceiverWhitelistOwnable2Step._msgSender
+FNDA:10,RuleReceiverWhitelistOwnable2Step._msgSender
+DA:73,10
+DA:80,1
+FN:80,RuleReceiverWhitelistOwnable2Step._msgData
+FNDA:1,RuleReceiverWhitelistOwnable2Step._msgData
+DA:81,1
+DA:88,12
+FN:88,RuleReceiverWhitelistOwnable2Step._contextSuffixLength
+FNDA:12,RuleReceiverWhitelistOwnable2Step._contextSuffixLength
+DA:89,12
+FNF:6
+FNH:6
+LF:11
+LH:11
+BRF:0
+BRH:0
+end_of_record
+TN:
SF:src/rules/validation/deployment/RuleSanctionsList.sol
DA:40,58
FN:40,RuleSanctionsList.supportsInterface
@@ -2329,35 +3474,35 @@ BRH:0
end_of_record
TN:
SF:src/rules/validation/deployment/RuleWhitelist.sol
-DA:47,47
+DA:47,86
FN:47,RuleWhitelist.supportsInterface
-FNDA:47,RuleWhitelist.supportsInterface
-DA:54,47
-DA:55,32
+FNDA:86,RuleWhitelist.supportsInterface
+DA:54,86
+DA:55,58
DA:65,1
FN:65,RuleWhitelist._authorizeCheckSpenderManager
FNDA:1,RuleWhitelist._authorizeCheckSpenderManager
-DA:70,10
+DA:70,30
FN:70,RuleWhitelist._authorizeMintBurnManager
-FNDA:10,RuleWhitelist._authorizeMintBurnManager
-DA:75,367
+FNDA:30,RuleWhitelist._authorizeMintBurnManager
+DA:75,394
FN:75,RuleWhitelist._authorizeAddressListAdd
-FNDA:367,RuleWhitelist._authorizeAddressListAdd
-DA:80,263
+FNDA:394,RuleWhitelist._authorizeAddressListAdd
+DA:80,264
FN:80,RuleWhitelist._authorizeAddressListRemove
-FNDA:263,RuleWhitelist._authorizeAddressListRemove
-DA:90,827
+FNDA:264,RuleWhitelist._authorizeAddressListRemove
+DA:90,888
FN:90,RuleWhitelist._msgSender
-FNDA:827,RuleWhitelist._msgSender
-DA:91,827
+FNDA:888,RuleWhitelist._msgSender
+DA:91,888
DA:98,1
FN:98,RuleWhitelist._msgData
FNDA:1,RuleWhitelist._msgData
DA:99,1
-DA:106,828
+DA:106,889
FN:106,RuleWhitelist._contextSuffixLength
-FNDA:828,RuleWhitelist._contextSuffixLength
-DA:107,828
+FNDA:889,RuleWhitelist._contextSuffixLength
+DA:107,889
FNF:8
FNH:8
LF:13
@@ -2414,26 +3559,26 @@ FN:56,RuleWhitelistWrapper.supportsInterface
FNDA:47,RuleWhitelistWrapper.supportsInterface
DA:63,47
DA:64,32
-DA:74,2
-FN:74,RuleWhitelistWrapper._authorizeCheckSpenderManager
+DA:77,49
+FN:77,RuleWhitelistWrapper._grantRole
+FNDA:49,RuleWhitelistWrapper._grantRole
+DA:78,49
+DA:87,1
+FN:87,RuleWhitelistWrapper._revokeRole
+FNDA:1,RuleWhitelistWrapper._revokeRole
+DA:88,1
+DA:98,2
+FN:98,RuleWhitelistWrapper._authorizeCheckSpenderManager
FNDA:2,RuleWhitelistWrapper._authorizeCheckSpenderManager
-DA:79,4
-FN:79,RuleWhitelistWrapper._authorizeMintBurnManager
+DA:103,4
+FN:103,RuleWhitelistWrapper._authorizeMintBurnManager
FNDA:4,RuleWhitelistWrapper._authorizeMintBurnManager
-DA:85,98
-FN:85,RuleWhitelistWrapper._onlyRulesManager
+DA:109,98
+FN:109,RuleWhitelistWrapper._onlyRulesManager
FNDA:98,RuleWhitelistWrapper._onlyRulesManager
-DA:90,2
-FN:90,RuleWhitelistWrapper._onlyRulesLimitManager
+DA:114,2
+FN:114,RuleWhitelistWrapper._onlyRulesLimitManager
FNDA:2,RuleWhitelistWrapper._onlyRulesLimitManager
-DA:102,49
-FN:102,RuleWhitelistWrapper._grantRole
-FNDA:49,RuleWhitelistWrapper._grantRole
-DA:103,49
-DA:112,1
-FN:112,RuleWhitelistWrapper._revokeRole
-FNDA:1,RuleWhitelistWrapper._revokeRole
-DA:113,1
DA:120,158
FN:120,RuleWhitelistWrapper._msgSender
FNDA:158,RuleWhitelistWrapper._msgSender
@@ -2491,3 +3636,360 @@ LH:13
BRF:0
BRH:0
end_of_record
+TN:
+SF:test/RuleBlacklist/Ownable/RuleBlacklistOwnable2Step.t.sol
+DA:10,2
+FN:10,RuleBlacklistOwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleBlacklistOwnable2StepTest._deployOwnable2Step
+DA:11,2
+DA:12,2
+DA:13,2
+FNF:1
+FNH:1
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleBlacklist/Ownable/RuleBlacklistOwnableAccessControl.t.sol
+DA:9,2
+FN:9,RuleBlacklistOwnable2StepAccessControl._deployAddressList
+FNDA:2,RuleBlacklistOwnable2StepAccessControl._deployAddressList
+DA:10,2
+DA:11,2
+DA:12,2
+FNF:1
+FNH:1
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleChainlinkPoR/Ownable/RuleChainlinkPoROwnable2Step.t.sol
+DA:17,2
+FN:17,RuleChainlinkPoROwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleChainlinkPoROwnable2StepTest._deployOwnable2Step
+DA:18,2
+DA:19,2
+DA:20,2
+DA:21,2
+DA:24,2
+FNF:1
+FNH:1
+LF:6
+LH:6
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleChainlinkPoR/RuleChainlinkPoRUnit.t.sol
+DA:627,1
+FN:627,DecimalsOnlyMock.decimals
+FNDA:1,DecimalsOnlyMock.decimals
+DA:628,1
+FNF:1
+FNH:1
+LF:2
+LH:2
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleERC2980/Ownable/RuleERC2980Ownable2Step.t.sol
+DA:10,2
+FN:10,RuleERC2980Ownable2StepTest._deployOwnable2Step
+FNDA:2,RuleERC2980Ownable2StepTest._deployOwnable2Step
+DA:11,2
+DA:12,2
+DA:13,2
+FNF:1
+FNH:1
+LF:4
+LH:4
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleIdentityRegistry/Ownable/RuleIdentityRegistryOwnable2Step.t.sol
+DA:11,2
+FN:11,RuleIdentityRegistryOwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleIdentityRegistryOwnable2StepTest._deployOwnable2Step
+DA:12,2
+DA:13,2
+DA:14,2
+DA:15,2
+DA:16,2
+FNF:1
+FNH:1
+LF:6
+LH:6
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleMaxTotalSupply/Ownable/RuleMaxTotalSupplyOwnable2Step.t.sol
+DA:11,2
+FN:11,RuleMaxTotalSupplyOwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleMaxTotalSupplyOwnable2StepTest._deployOwnable2Step
+DA:12,2
+DA:13,2
+DA:14,2
+DA:15,2
+FNF:1
+FNH:1
+LF:5
+LH:5
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleMaxTotalSupply/RuleMaxTotalSupplyUnit.t.sol
+DA:211,2
+FN:211,RevertingTotalSupplyMock.setRevertOnTotalSupply
+FNDA:2,RevertingTotalSupplyMock.setRevertOnTotalSupply
+DA:212,2
+DA:215,6
+FN:215,RevertingTotalSupplyMock.totalSupply
+FNDA:6,RevertingTotalSupplyMock.totalSupply
+DA:216,6
+BRDA:216,0,0,4
+BRDA:216,0,1,2
+DA:217,2
+FNF:2
+FNH:2
+LF:5
+LH:5
+BRF:2
+BRH:2
+end_of_record
+TN:
+SF:test/RuleSanctionList/Ownable/RuleSanctionsListOwnable2Step.t.sol
+DA:13,2
+FN:13,RuleSanctionsListOwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleSanctionsListOwnable2StepTest._deployOwnable2Step
+DA:14,2
+DA:15,2
+DA:16,2
+DA:17,2
+FNF:1
+FNH:1
+LF:5
+LH:5
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/RuleWhitelist/Ownable/RuleWhitelistOwnable2Step.t.sol
+DA:10,2
+FN:10,RuleWhitelistOwnable2StepTest._deployOwnable2Step
+FNDA:2,RuleWhitelistOwnable2StepTest._deployOwnable2Step
+DA:11,2
+DA:12,2
+DA:13,2
+DA:14,2
+FNF:1
+FNH:1
+LF:5
+LH:5
+BRF:0
+BRH:0
+end_of_record
+TN:
+SF:test/invariant/ConditionalTransferHandler.sol
+DA:61,2
+FN:61,ConditionalTransferHandler.constructor
+FNDA:2,ConditionalTransferHandler.constructor
+DA:62,2
+DA:72,4152
+FN:72,ConditionalTransferHandler.approve
+FNDA:4152,ConditionalTransferHandler.approve
+DA:73,4152
+DA:74,4152
+DA:75,4152
+DA:76,4152
+DA:82,4270
+FN:82,ConditionalTransferHandler.cancel
+FNDA:4270,ConditionalTransferHandler.cancel
+DA:83,4270
+BRDA:83,0,0,4270
+DA:84,4270
+DA:86,4114
+DA:87,4114
+BRDA:87,1,0,2747
+DA:88,2747
+DA:90,1367
+DA:91,1367
+DA:97,4190
+FN:97,ConditionalTransferHandler.execute
+FNDA:4190,ConditionalTransferHandler.execute
+DA:98,4190
+BRDA:98,2,0,4190
+DA:99,4190
+DA:101,4048
+DA:102,4048
+BRDA:102,3,0,2577
+DA:103,2577
+DA:105,1471
+DA:106,1471
+DA:114,4028
+FN:114,ConditionalTransferHandler.executeMintOrBurn
+FNDA:4028,ConditionalTransferHandler.executeMintOrBurn
+DA:115,4028
+DA:116,4028
+DA:117,4028
+BRDA:117,4,0,1998
+BRDA:117,4,1,2030
+DA:118,1998
+DA:120,2030
+DA:122,4028
+DA:132,0
+FN:132,ConditionalTransferHandler.sumApprovalCounts
+FNDA:0,ConditionalTransferHandler.sumApprovalCounts
+DA:133,0
+DA:134,0
+DA:135,0
+DA:142,0
+FN:142,ConditionalTransferHandler.keyCount
+FNDA:0,ConditionalTransferHandler.keyCount
+DA:143,0
+DA:150,4152
+FN:150,ConditionalTransferHandler._tuple
+FNDA:4152,ConditionalTransferHandler._tuple
+DA:156,4152
+DA:157,4152
+DA:158,4152
+DA:161,4152
+FN:161,ConditionalTransferHandler._record
+FNDA:4152,ConditionalTransferHandler._record
+DA:162,4152
+DA:163,4152
+BRDA:163,5,0,2965
+DA:164,2965
+DA:165,2965
+FNF:9
+FNH:7
+LF:45
+LH:39
+BRF:7
+BRH:7
+end_of_record
+TN:
+SF:test/invariant/MintAllowanceHandler.sol
+DA:55,2
+FN:55,MintAllowanceHandler.constructor
+FNDA:2,MintAllowanceHandler.constructor
+DA:56,2
+DA:66,3454
+FN:66,MintAllowanceHandler.setAllowance
+FNDA:3454,MintAllowanceHandler.setAllowance
+DA:67,3454
+DA:68,3454
+DA:69,3454
+DA:70,3454
+DA:71,3454
+DA:77,3236
+FN:77,MintAllowanceHandler.increase
+FNDA:3236,MintAllowanceHandler.increase
+DA:78,3236
+DA:79,3236
+DA:80,3236
+DA:81,3236
+DA:82,3236
+DA:88,3376
+FN:88,MintAllowanceHandler.decrease
+FNDA:3376,MintAllowanceHandler.decrease
+DA:89,3376
+DA:90,3376
+DA:91,3376
+BRDA:91,0,0,287
+DA:92,287
+DA:94,3089
+DA:95,3089
+DA:96,3089
+DA:102,3304
+FN:102,MintAllowanceHandler.mint
+FNDA:3304,MintAllowanceHandler.mint
+DA:103,3304
+DA:104,3304
+DA:105,3304
+BRDA:105,1,0,264
+DA:106,264
+DA:108,3040
+DA:109,3040
+DA:111,3040
+DA:113,3040
+DA:114,3040
+DA:121,3270
+FN:121,MintAllowanceHandler.regularTransfer
+FNDA:3270,MintAllowanceHandler.regularTransfer
+DA:122,3270
+DA:123,3270
+DA:124,3270
+DA:125,3270
+DA:135,0
+FN:135,MintAllowanceHandler.minterAt
+FNDA:0,MintAllowanceHandler.minterAt
+DA:136,0
+DA:137,0
+DA:143,0
+FN:143,MintAllowanceHandler.minterCount
+FNDA:0,MintAllowanceHandler.minterCount
+DA:144,0
+DA:151,16640
+FN:151,MintAllowanceHandler._minter
+FNDA:16640,MintAllowanceHandler._minter
+DA:152,16640
+FNF:9
+FNH:7
+LF:44
+LH:39
+BRF:2
+BRH:2
+end_of_record
+TN:
+SF:test/utils/AccessControlEnumerableTestBase.sol
+DA:22,0
+FN:22,AccessControlEnumerableTestBase._deployAccessControl
+FNDA:0,AccessControlEnumerableTestBase._deployAccessControl
+DA:24,7
+FN:24,AccessControlEnumerableTestBase.setUp
+FNDA:7,AccessControlEnumerableTestBase.setUp
+DA:25,7
+DA:28,14
+FN:28,AccessControlEnumerableTestBase._assertRoleMembers
+FNDA:14,AccessControlEnumerableTestBase._assertRoleMembers
+DA:29,14
+DA:30,14
+DA:31,14
+BRDA:31,0,0,7
+DA:32,7
+DA:34,7
+DA:35,7
+DA:36,7
+FNF:3
+FNH:2
+LF:11
+LH:10
+BRF:1
+BRH:1
+end_of_record
+TN:
+SF:test/utils/CMTATDeployment.sol
+DA:15,78
+FN:15,CMTATDeployment.constructor
+FNDA:78,CMTATDeployment.constructor
+DA:17,78
+DA:18,78
+DA:19,78
+DA:20,78
+DA:27,78
+DA:28,78
+FNF:1
+FNH:1
+LF:7
+LH:7
+BRF:0
+BRH:0
+end_of_record
diff --git a/doc/schema/architecture-topologies.png b/doc/schema/architecture-topologies.png
new file mode 100644
index 00000000..ddebf584
Binary files /dev/null and b/doc/schema/architecture-topologies.png differ
diff --git a/doc/schema/architecture-topologies.puml b/doc/schema/architecture-topologies.puml
new file mode 100644
index 00000000..b03d42a2
--- /dev/null
+++ b/doc/schema/architecture-topologies.puml
@@ -0,0 +1,42 @@
+@startuml
+' Source for doc/schema/architecture-topologies.png, embedded in the root README.
+' Render with: plantuml -tpng doc/schema/architecture-topologies.puml
+title The two integration topologies
+
+skinparam backgroundColor transparent
+skinparam shadowing false
+skinparam defaultFontName Helvetica
+skinparam defaultFontSize 12
+skinparam roundCorner 8
+skinparam ArrowColor #33475B
+skinparam ArrowFontSize 11
+skinparam rectangle {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+ FontColor #1B2733
+}
+
+rectangle "**Topology A** — RuleEngine //(compose several rules)//" as A #F4F8FC {
+ rectangle "CMTAT token" as tokenA
+ rectangle "RuleEngine" as engine
+ rectangle "RuleBlacklist\nmsg.sender == RuleEngine" as r1
+ rectangle "RuleSanctionsList\nmsg.sender == RuleEngine" as r2
+ rectangle "RuleMaxTotalSupply\nmsg.sender == RuleEngine" as r3
+
+ tokenA -down-> engine : transferred(from, to, value)\n//transfer()//
+tokenA -down-> engine : transferred(spender, from, to, value)\n//transferFrom(), mint, burn//
+ engine -down-> r1
+ engine -down-> r2
+ engine -down-> r3
+}
+
+rectangle "**Topology B** — direct binding //(a single rule)//" as B #F4F8FC {
+ rectangle "CMTAT token " as tokenB
+ rectangle "RuleWhitelist\nmsg.sender == CMTAT token" as ruleB
+
+ tokenB -down-> ruleB : transferred(from, to, value)\n//transfer()//
+tokenB -down-> ruleB : transferred(spender, from, to, value)\n//transferFrom(), mint, burn//
+}
+
+A -[hidden]down- B
+@enduml
diff --git a/doc/schema/erc3643-identity-directions.png b/doc/schema/erc3643-identity-directions.png
new file mode 100644
index 00000000..55bbb576
Binary files /dev/null and b/doc/schema/erc3643-identity-directions.png differ
diff --git a/doc/schema/erc3643-identity-directions.puml b/doc/schema/erc3643-identity-directions.puml
new file mode 100644
index 00000000..f2fed799
--- /dev/null
+++ b/doc/schema/erc3643-identity-directions.puml
@@ -0,0 +1,56 @@
+@startuml
+' Source for doc/schema/erc3643-identity-directions.png, embedded in the root README.
+' Render with: plantuml -tpng doc/schema/erc3643-identity-directions.puml
+title Identity verification — which route applies depends on whether the token has an identity slot
+
+skinparam backgroundColor transparent
+skinparam shadowing false
+skinparam defaultFontName Helvetica
+skinparam defaultFontSize 12
+skinparam roundCorner 8
+skinparam ArrowColor #33475B
+skinparam ArrowFontSize 11
+skinparam rectangle {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+ FontColor #1B2733
+}
+
+rectangle "**ERC-3643 token** — it has an identity slot" as B #E8F5E9 {
+ rectangle "ERC-3643 token\nToken.setIdentityRegistry(...)" as tB
+ rectangle "IdentityRegistryWhitelist\n**is** a registry — answers from a whitelist" as regB #E3F2FD
+
+ tB -down-> regB : isVerified(wallet)?
+}
+
+rectangle "**CMTAT token** — no identity slot exists" as A #FFF3E0 {
+ rectangle "CMTAT token" as tA
+ rectangle "RuleEngine" as eA
+ rectangle "RuleIdentityRegistry\n**consults** a registry" as ruleA #FFECB3
+ rectangle "Your ONCHAINID-backed\nERC-3643 registry" as regA
+ rectangle "IdentityRegistryWhitelist\nif you have no ONCHAINID" as regC #E3F2FD
+
+ tA -down-> eA : transferred(...)
+ eA -down-> ruleA
+ ruleA -down-> regA : isVerified(wallet)?
+ ruleA -down-> regC : isVerified(wallet)?
+}
+
+note bottom of B
+ Plug the registry **straight into the slot**. The token
+ screens every transfer itself. Do **not** also add
+ ""RuleIdentityRegistry"" to a RuleEngine here — the token
+ already consults the registry, so it would only screen
+ the same wallets twice.
+end note
+
+note bottom of A
+ CMTAT has **no** ""setIdentityRegistry"", so a rule is the only
+ route: the RuleEngine path is not one option among several.
+ Either registry works — the rule holds an
+ ""IIdentityRegistryVerified"" and only calls ""isVerified"".
+ Pinned by CMTATRuleIdentityRegistryComposition.t.sol
+end note
+
+B -[hidden]right- A
+@enduml
diff --git a/doc/schema/erc3643-slots.png b/doc/schema/erc3643-slots.png
new file mode 100644
index 00000000..7d340ba0
Binary files /dev/null and b/doc/schema/erc3643-slots.png differ
diff --git a/doc/schema/erc3643-slots.puml b/doc/schema/erc3643-slots.puml
new file mode 100644
index 00000000..0e1d7655
--- /dev/null
+++ b/doc/schema/erc3643-slots.puml
@@ -0,0 +1,49 @@
+@startuml
+' Source for doc/schema/erc3643-slots.png, embedded in the root README (ERC-3643 section).
+' Render with: plantuml -tpng doc/schema/erc3643-slots.puml
+title An ERC-3643 token has two pluggable slots — this library fills both
+
+skinparam backgroundColor transparent
+skinparam shadowing false
+skinparam defaultFontName Helvetica
+skinparam defaultFontSize 12
+skinparam roundCorner 8
+skinparam ArrowColor #33475B
+skinparam ArrowFontSize 11
+skinparam rectangle {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+ FontColor #1B2733
+}
+
+rectangle "ERC-3643 token\nToken.sol" as token #FFF3E0
+
+package "Compliance slot" as comp #F4F8FC {
+ rectangle "RuleEngine" as engine
+ rectangle "RuleWhitelist" as r1
+ rectangle "RuleBlacklist" as r2
+ rectangle "RuleSanctionsList" as r3
+ engine -down-> r1
+ engine -down-> r2
+ engine -down-> r3
+}
+
+package "Identity registry slot" as idp #F4F8FC {
+ rectangle "IdentityRegistryWhitelist\nanswers from a whitelist\nno ONCHAINID needed" as idreg
+}
+
+token -down-> engine : "canTransfer / transferred\ncreated / destroyed\n//may this move happen?//"
+token -down-> idreg : "isVerified(wallet)\n//is this a verified investor?//"
+
+note bottom of comp
+ Use a **RuleEngine**, not a bare rule: ERC-3643 drives mint
+ and burn through ""created"" / ""destroyed"", which the
+ validation rules do not implement.
+end note
+
+note bottom of idp
+ Installed with ""token.setIdentityRegistry(...)"".
+ **Not** a rule — never add it to a RuleEngine.
+end note
+
+@enduml
diff --git a/doc/schema/rule-direct.png b/doc/schema/rule-direct.png
new file mode 100644
index 00000000..73c1959d
Binary files /dev/null and b/doc/schema/rule-direct.png differ
diff --git a/doc/schema/rule-direct.puml b/doc/schema/rule-direct.puml
new file mode 100644
index 00000000..c7cd2f46
--- /dev/null
+++ b/doc/schema/rule-direct.puml
@@ -0,0 +1,52 @@
+@startuml
+' Source for doc/schema/rule-direct.png, embedded in doc/README.md.
+' Render with: plantuml -tpng doc/schema/rule-direct.puml
+title Topology B — a single rule bound directly to the token
+
+skinparam backgroundColor transparent
+skinparam shadowing false
+skinparam defaultFontName Helvetica
+skinparam defaultFontSize 12
+skinparam roundCorner 8
+skinparam ArrowColor #33475B
+skinparam ArrowFontSize 11
+skinparam rectangle {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+ FontColor #1B2733
+}
+skinparam actor {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+}
+
+actor "Token holder\n//or spender//" as holder
+
+rectangle "CMTAT\ntoken.setRuleEngine(rule)" as cmtat #FFF3E0
+rectangle "RuleWhitelist\nmsg.sender == the token" as rule #E3F2FD
+
+rectangle "ERC-3643 token" as erc #FFEBEE
+rectangle "a bare validation rule" as bad #FFEBEE
+
+holder -right-> cmtat : **1.** transfer(to, value)\ntransferFrom(from, to, value)
+cmtat -right-> rule : **2.** transferred(from, to, value)\ntransferred(spender, from, to, value)
+
+erc -right[#C62828,dashed]-> bad : **not supported**
+
+note bottom of rule
+ No engine in between, so inside the rule ""msg.sender"" is the
+ **token itself**. One contract fewer and no engine hop per
+ transfer. Fine for a single validation rule; required for
+ ""RuleConditionalTransferLightMultiToken"".
+end note
+
+note bottom of bad
+ **An ERC-3643 token cannot be backed by a bare rule.**
+ It drives mint and burn through ""created"" / ""destroyed"",
+ which the validation rules do not implement. Use a
+ ""RuleEngine"" (Topology A) instead.
+end note
+
+cmtat -[hidden]down- erc
+
+@enduml
diff --git a/doc/schema/rule-via-ruleengine.png b/doc/schema/rule-via-ruleengine.png
new file mode 100644
index 00000000..49fcb1ed
Binary files /dev/null and b/doc/schema/rule-via-ruleengine.png differ
diff --git a/doc/schema/rule-via-ruleengine.puml b/doc/schema/rule-via-ruleengine.puml
new file mode 100644
index 00000000..130d48eb
--- /dev/null
+++ b/doc/schema/rule-via-ruleengine.puml
@@ -0,0 +1,57 @@
+@startuml
+' Source for doc/schema/rule-via-ruleengine.png, embedded in doc/README.md.
+' Render with: plantuml -tpng doc/schema/rule-via-ruleengine.puml
+title Topology A — several rules composed behind a RuleEngine
+
+skinparam backgroundColor transparent
+skinparam shadowing false
+skinparam defaultFontName Helvetica
+skinparam defaultFontSize 12
+skinparam roundCorner 8
+skinparam ArrowColor #33475B
+skinparam ArrowFontSize 11
+skinparam rectangle {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+ FontColor #1B2733
+}
+skinparam actor {
+ BorderColor #33475B
+ BackgroundColor #FFFFFF
+}
+
+actor "Token holder\n//or spender//" as holder
+
+rectangle "CMTAT" as cmtat #FFF3E0
+rectangle "ERC-3643 token" as erc #E8F5E9
+rectangle "RuleEngine" as engine #E3F2FD
+
+rectangle "RuleWhitelist" as r0
+rectangle "RuleBlacklist" as r1
+rectangle "RuleSanctionsList" as rn
+
+holder -down-> cmtat : **1.** transfer(to, value)\ntransferFrom(from, to, value)
+holder -down-> erc : **1.** transfer(to, value)\ntransferFrom(from, to, value)
+
+cmtat -down-> engine : **2.** transferred(from, to, value)\ntransferred(spender, from, to, value)
+erc -down-> engine : **2.** transferred(...)\ncreated / destroyed
+
+engine -down-> r0 : **3a.**
+engine -down-> r1 : **3b.**
+engine -down-> rn : **3c.**
+
+note right of engine
+ Evaluates each rule in order and returns the **first non-zero**
+ restriction code, so rule order decides which code a rejection
+ reports — not whether it is rejected.
+
+ Inside every rule ""msg.sender"" is the **RuleEngine**, not the token.
+end note
+
+note right of erc
+ **ERC-3643 requires this topology.** The token drives mint and burn
+ through ""created"" / ""destroyed"", which the validation rules do not
+ implement — only ""RuleEngine"" implements the full ""ICompliance"" surface.
+end note
+
+@enduml
diff --git a/doc/script/convert_links_for_pdf.sh b/doc/script/convert_links_for_pdf.sh
index 2f4d3926..5b2a4f6c 100755
--- a/doc/script/convert_links_for_pdf.sh
+++ b/doc/script/convert_links_for_pdf.sh
@@ -10,12 +10,12 @@ if [ -z "$1" ]; then
echo ""
echo "Example:"
echo " $0 https://github.com/CMTA/CMTAT/blob/v3.0.0"
- echo " $0 https://github.com/CMTA/CMTAT/blob/v3.0.0 README.md README_UPDATE.md"
+ echo " $0 https://github.com/CMTA/CMTAT/blob/v3.0.0 ../README.md README_UPDATE.md"
exit 1
fi
GITHUB_LINK="${1%/}" # Remove trailing slash if present
-INPUT_FILE="${2:-../../README.md}"
+INPUT_FILE="${2:-../README.md}" # doc/README.md, the full reference (the root README is a short summary)
OUTPUT_FILE="${3:-README_UPDATE.md}"
if [ ! -f "$INPUT_FILE" ]; then
diff --git a/doc/script/script_surya_inheritance.sh b/doc/script/script_surya_inheritance.sh
index e4b8ca7d..bd58fe9b 100755
--- a/doc/script/script_surya_inheritance.sh
+++ b/doc/script/script_surya_inheritance.sh
@@ -7,7 +7,9 @@ if ! [ -d "$DIR_OUT" ]; then
fi
cd './src'
DIR=$(pwd)
-for i in $(find $dir -type f);
+# Deliberately relative: surya records the path it was given in its output, so an absolute
+# path here would bake the checkout location into the committed reports.
+for i in $(find . -type f);
do
filename=${i##*/}
ext=${i##*.}
diff --git a/doc/script/script_surya_report.sh b/doc/script/script_surya_report.sh
index 84769c9f..fa8c0a5c 100755
--- a/doc/script/script_surya_report.sh
+++ b/doc/script/script_surya_report.sh
@@ -3,11 +3,13 @@ cd '../../'
DIR=$(pwd)
DIR_OUT=${DIR}/docOut/surya_report
if ! [ -d "$DIR_OUT" ]; then
- mkdir ./docOut/surya_report
+ mkdir -p ./docOut/surya_report
fi
cd './src'
DIR=$(pwd)
-for i in $(find $dir -type f);
+# Deliberately relative: surya records the path it was given in its output, so an absolute
+# path here would bake the checkout location into the committed reports.
+for i in $(find . -type f);
do
filename=${i##*/}
ext=${i##*.}
diff --git a/doc/security/audits/AUDIT_OVERVIEW.md b/doc/security/audits/AUDIT_OVERVIEW.md
index 79678fe1..ebc4bd56 100644
--- a/doc/security/audits/AUDIT_OVERVIEW.md
+++ b/doc/security/audits/AUDIT_OVERVIEW.md
@@ -3,7 +3,7 @@
> This is a security **overview** (analyses index + triage). It is **not** the vulnerability-reporting policy
> (that belongs in a root `SECURITY.md`).
-**Current package version:** `v0.4.0`
+**Current package version:** `v0.5.0`
**Scope:** production contracts under `src/` — mocks/tests (`src/mocks`, `test/`) and dependencies (`lib/`) are excluded from static-analysis runs unless a run is explicitly marked *mocks included*.
> ⚠️ This project has **not** undergone a formal third-party security audit. The analyses below are automated
@@ -13,12 +13,53 @@
| Date | Type | Tool / Source | Version | Reports |
|---|---|---|---|---|
+| 2026-08-12 | AI-assisted review | Claude Code (Anthropic) | v0.5.0 | [**CLAUDE_ANALYSIS.md**](./tools/v0.5.0/CLAUDE_ANALYSIS.md) (code quality, `src/`) · [**CLAUDE_ANALYSIS_SCRIPT.md**](./tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md) (deployment scripts) |
| 2026-07 | AI-assisted review | Claude (Anthropic) + custom security-audit skills | v0.4.0 | [**CLAUDE_AUDIT.md**](./tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) |
+| 2026-08-11 | Static analysis | Slither 0.11.5 | v0.5.0 | [report](./tools/v0.5.0/slither-report.md) · [feedback](./tools/v0.5.0/slither-report-feedback.md) |
+| 2026-08-11 | Static analysis | Aderyn 0.6.5 | v0.5.0 | [report](./tools/v0.5.0/aderyn-report.md) · [feedback](./tools/v0.5.0/aderyn-report-feedback.md) |
| 2026-07-14 | Static analysis | Slither 0.11.5 | v0.4.0 | [report](./tools/v0.4.0/slither-report.md) · [feedback](./tools/v0.4.0/slither-report-feedback.md) |
| 2026-07-14 | Static analysis | Aderyn 0.6.5 | v0.4.0 | [report](./tools/v0.4.0/aderyn-report.md) · [feedback](./tools/v0.4.0/aderyn-report-feedback.md) |
| 2026-04-16 | Static analysis | Slither / Aderyn | v0.3.0 | [slither](./tools/v0.3.0/slither-report.md) · [aderyn](./tools/v0.3.0/aderyn-report.md) |
| 2026-03-16 | AI-assisted review | Wake Arena (Ackee) | v0.2.0 | [tools/v0.2.0](./tools/v0.2.0/) |
+## Static-analysis results (v0.5.0)
+
+Scope: production contracts only — mocks excluded (`-x mocks` / `mocks` filter) and vendored dependencies
+excluded via the `lib` filter. Run **2026-08-13** at solc `0.8.36`, superseding the earlier `v0.5.0` runs.
+
+| Tool | High | Medium | Low | Info | Relevant to fix? |
+|---|---|---|---|---|---|
+| Slither 0.11.5 | 2 | 11 | 17 | 14 | **No** — all false-positive, by-design or cosmetic; see [feedback](./tools/v0.5.0/slither-report-feedback.md) |
+| Aderyn 0.6.5 | 0 | 0 | 9 categories (336 instances) | 0 | **No** — all Low, by-design / environment / cosmetic; see [feedback](./tools/v0.5.0/aderyn-report-feedback.md) |
+
+**Nothing to fix in `v0.5.0`.** Every delta from v0.4.0 traces to the three contracts added in this release
+(`RuleChainlinkPoR`, `RuleReceiverWhitelist`, `IdentityRegistryWhitelist`) and each was verified against the
+source before dismissal. The two new Slither categories are `uninitialized-local` (variables assigned inside a
+`try` whose `catch` reverts or returns) and `timestamp` (the Proof-of-Reserve staleness comparison, which is the
+feature itself).
+
+**Latest re-run (2026-08-13, solc `0.8.36`, after the cap-manager split): Slither 44, Aderyn 336 instances.** The split of `RuleMaxTotalSupplyBase` / `RuleMaxBalanceBase` into `TotalSupplyCapManager` / `BalanceCapManager` moved **no** detector: Slither reports the same 44 results one for one, and Aderyn's only change is one pragma and one PUSH0 instance per new file. Storage layout and ABI were separately verified identical for all four affected deployable contracts. The seven contracts added for
+`RuleMaxBalance` and the `ChainlinkPoRFeedManager` split produced exactly **one** new Slither finding — a
+`balanceOf` configuration probe whose discarded return value is the point — and **no** new Aderyn category. The
+dependency and compiler bumps (solc `0.8.36`, OpenZeppelin `v5.7.0`, RuleEngine `v3.0.0-rc5`, CMTAT
+`v3.3.0-rc3`) moved no detector at all. An earlier re-run the same day had lowered both counts (Slither 46 → 43,
+Aderyn 333 → 315) while contract count and nSLOC rose. The reduction is earned by the `AddressSetBatchLib` refactor, which replaced duplicated batch
+loops with one shared implementation that consumes the `EnumerableSet` return values instead of discarding them;
+Aderyn's *Loop Contains `require`/`revert`* category disappeared entirely. One informational disposition was
+**corrected** rather than re-confirmed: Slither's `unused-state` on the four `TRANSFERRED_SELECTOR_*` constants
+in `RuleNFTAdapter` was previously dismissed as a false positive, but each constant occurs exactly once in the
+repository — its own declaration. They are genuinely unreferenced. Impact is nil (`internal constant`, so no
+storage and not emitted into bytecode), so the disposition is cosmetic rather than a fix.
+
+Note for readers comparing runs: the Slither command must filter **`lib`**, not `submodules` — this is a Foundry
+project, so a generic filter pulls the whole vendored dependency tree into scope and inflates the count roughly
+four-fold with OpenZeppelin-internal findings.
+
+The substantive issues fixed in this release — the guarded `totalSupply()` reads (codes 51 / 78), the live
+feed-decimals read that prevents a stale-cache over-mint, and the removal of two inert public roles from
+`IdentityRegistryWhitelist` — were found by **manual review, not by either tool**. A clean static-analysis report
+means the tools' pattern sets matched nothing; it is not evidence of correctness.
+
## Static-analysis results (v0.4.0)
Both tools were **re-run on 2026-07-14**, after the security remediation landed. Counts below are from that run.
diff --git a/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md
new file mode 100644
index 00000000..3a61dbed
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS.md
@@ -0,0 +1,1059 @@
+# Claude Code Analysis — Code Quality Review
+
+Report version: `v0.5.0`
+Tool: **Claude Code** (Anthropic) — interactive review and implementation session, model Opus 5
+Scope: `src/` (rules, registry, modules) at commit `5ed2727`, branch `dev`. `src/mocks/` and `test/` reviewed
+only where they establish a caller contract. `lib/` is out of scope.
+Compiler: solc `0.8.34`, optimizer on (200 runs), EVM `prague`
+Review date: 2026-08-12
+
+**Produced with Claude Code.** The findings below were identified by Claude Code reading the source directly —
+this is not the output of a static-analysis tool, so there is no accompanying machine-generated report to triage
+(unlike [slither-report.md](./slither-report.md) and [aderyn-report.md](./aderyn-report.md)). Every finding was
+then implemented, deliberately declined, or corrected in the same session, and each gas figure below was
+**measured** rather than estimated.
+
+**Requested axes:** code quality, duplication, missing events, `for`-loop gas (`++i`), storage-read caching, and
+behaviour that is technically correct but at odds with the project's purpose.
+
+**Nothing here is a vulnerability.** No finding lets an unauthorized party move value, bypass a restriction, or
+brick a rule. This is a maintainability, observability and gas review. Two findings turned out to be wrong as
+originally written and are corrected in place (**B-1**, **B-4**); one proposed fix did not work and was replaced
+(**F-2**). Those corrections are kept visible rather than silently rewritten.
+
+---
+
+## Disposition summary
+
+Every finding, its outcome, and the commit that carries it.
+
+| ID | Finding | Outcome | Commit |
+|---|---|---|---|
+| **A-1** | `for` loops already use `++i`; `unchecked` must NOT be added (solc ≥ 0.8.22 elides the check) | ✅ Nothing to do — verified | — |
+| **A-2** | Wrapper child-rule scan rescanned the result array per rule | ✅ Fixed — ~85 gas/child | `ece992f` |
+| **A-3** | `areAddressesListed` takes `memory`, could be `calldata` | ⬜ **Not implemented** | — |
+| **B-1** | `approvalCounts` re-read for the event, 6 sites | ⚠️ **Partly fixed — finding was wrong for 4 of 6.** Only the 2 `+= 1` sites benefit (−109 gas); the optimizer already handled the other 4, where the "fix" cost 12 gas | `bf5bd76` |
+| **B-2** | `sanctionsList` read up to 5× per check | ✅ Fixed — 219–320 gas | `8051d6c` |
+| **B-3** | `identityRegistry` read up to 5× per check | ✅ Fixed — 108–320 gas (+5 on the unset-registry path, accepted) | `c6ae672` |
+| **B-4** | `contains()` then `add()`/`remove()`, 8 sites | ⚠️ **Fixed, but the finding overstated the gain ~7×** — ~288 gas/call, not ~2 100 | `55131de` |
+| **C-1** | `RuleMaxTotalSupply` constructor emitted nothing | ✅ Fixed | `d354ae1` |
+| **C-2** | `checkSpender` initial value never announced | ✅ Fixed | `d354ae1` |
+| **C-3** | `RuleIdentityRegistry` constructor omitted `IdentityRegistryUpdated` | ✅ Fixed | `d354ae1` |
+| **C-4** | Batch events report the input array, not the effect; counters computed then discarded | ✅ Fixed — the counters are now emitted. **Breaking**: six batch event signatures change, so `topic0` changes | `17d6eb8` |
+| **D-1** | `RuleERC2980Internal` duplicated `RuleAddressSetInternal` twice | ✅ Fixed — shared `AddressSetBatchLib`; storage layout verified identical | `bd3b6a7` |
+| **D-2** | `_currentSupply()` byte-identical in two rules | ✅ Fixed — stateless `TokenSupplyReader` base, −12 gas | `e4dd438` |
+| **D-3** | detect-then-`require` `_transferred` pair repeated in 9 rules | ⬜ **Left as is** — the per-rule custom error is the only variation and is worth keeping | — |
+| **D-4** | `checkSpender` machinery duplicated in both whitelist bases | ✅ Fixed — one definition site; ABI verified unchanged for 7 contracts | `b32c74d` |
+| **D-5** | `isVerified` duplicated `_isListedInAnyChild` | ✅ Fixed — −124 bytes bytecode, +27 gas/call | `e76a59b` |
+| **E-1** | 16 `internal` functions not `virtual` | ✅ Fixed — 0 gas cost, guarded by override harnesses | `8d60b59` |
+| **E-2** | `canTransfer` not `virtual` (plus its ERC-7943 twin) | ✅ Fixed — ~55 further non-`virtual` public views found, deliberately out of scope | `5ebbe43` |
+| **E-3** | 27 public mutating functions not `virtual` | ✅ Fixed — 0 gas cost; harness coverage is representative, 21 of 27 unguarded | `fad06a7` |
+| **F-1** | Sanctions oracle asked whether `address(0)` is sanctioned | ✅ Fixed — 2 830 gas/mint, removes a dependency on a third party's handling of a non-wallet | `b10021e` |
+| **F-2** | Sanctions `From` path skipped the direct check when the oracle is unset | ⚠️ **Fixed — the remedy proposed in the finding did not work** and was replaced; +221 gas on the disabled-oracle path, accepted | `63a9548` |
+| **F-3** | Dead `to != address(0)` term in `RuleIdentityRegistryBase` | ✅ Fixed — 49 gas, and the comment that credited it with the burn exemption corrected | `44c6681` |
+| **F-4** | `_transferHash` comment claimed "packed"; encoding is neither standard form | ✅ Fixed (option 1) — comment corrected, 96/128-byte preimage documented and pinned by tests; assembly kept (~109 gas cheaper, on the transfer write path) | `9c68056` |
+| **F-5** | Batch add reverts on `address(0)`, contradicting three documents | ✅ Fixed — **documentation only**, the code was right; README also contradicted itself | `8900a81` |
+| **F-6** | `RuleMintAllowance.canTransfer` hardcoded to `true` | ✅ Fixed (option 1) — **documentation + test, no code change**; the blind spot propagates to the RuleEngine *and* the token | `aa7a3a6` |
+| **F-7a** | Empty `INTERNAL FUNCTIONS` banner | ✅ Fixed | `636ecf6` |
+| **F-7b** | `version()` was `view`, returns a constant | ✅ Fixed — `pure`; changes the ABI `stateMutability` field only | `636ecf6` |
+| **F-7c** | `approveAndTransferIfAllowed` pre-checks `allowance` | ⬜ **Left as is** — ~2 600 gas buys a named error the bare token revert would not give | — |
+
+### Outstanding
+
+| ID | Item | Why it is still open |
+|---|---|---|
+| **A-3** | `areAddressesListed(address[] memory)` → `external` + `calldata` | Not attempted this session |
+| **D-3** | detect-then-`require` pair in 9 rules | Deliberate: collapsing it would either lose the per-rule error or need a hook returning revert data |
+| **F-7c** | Redundant `allowance` read | Deliberate: diagnostic quality over ~2 600 gas |
+
+### Related work in the same session, outside this report
+
+| Change | Commit |
+|---|---|
+| `RuleChainlinkPoR` documented as ERC-20 only; README ERC-7943 / `ITransferContext` support claims corrected | `5ed2727` |
+| CI: ONCHAINID context remapping scoped to the `erc3643` profile so `hardhat-foundry` can parse `remappings.txt`; CI now also runs the ERC-3643 profile, which it never had | `f50ac1c` |
+
+---
+
+## Summary
+
+| ID | Category | Item | Impact |
+|---|---|---|---|
+| **A-1** | Loop gas | All 13 `for` loops already use `++i` — **and `unchecked` must not be added** | ✅ none needed |
+| **A-2** | Loop gas | `_detectTransferRestrictionForTargets` rescans the whole result array per child rule | ✅ **implemented** — ~85 gas/child |
+| **A-3** | Loop gas | `areAddressesListed(address[] memory)` should be `calldata` | ~1 calldata→memory copy per wrapper child |
+| **B-1** | Storage read | Freshly-written `approvalCounts` slot re-read for the event, 6 sites | ✅ **partly implemented** — 2 of 6 sites; the other 4 were already optimized away |
+| **B-2** | Storage read | `sanctionsList` read up to 5× per `transferFrom` check | ✅ **implemented** — 219–320 gas measured |
+| **B-3** | Storage read | `identityRegistry` read up to 5× + guard evaluated twice | ✅ **implemented** — 108–320 gas measured |
+| **B-4** | Storage read | `contains()` then `add()`/`remove()` — double set lookup, 8 sites | ✅ **implemented** — ~288 gas/call, not the ~2 100 claimed |
+| **C-1** | Missing event | `RuleMaxTotalSupply` constructor emits nothing; its sibling `RuleChainlinkPoR` emits everything | ✅ **implemented** |
+| **C-2** | Missing event | `checkSpender`'s initial value never emitted, in both whitelist constructors | ✅ **implemented** |
+| **C-3** | Missing event | `RuleIdentityRegistry` constructor emits the two flags but not the registry address | ✅ **implemented** |
+| **C-4** | Missing event | Batch events report the *input array*, not the effect; the effect counters are computed then discarded | ✅ **implemented** — counters emitted; **breaking event-signature change** |
+| **D-1** | Duplication | `RuleERC2980Internal` is `RuleAddressSetInternal` copied twice (~190 lines) | ✅ **implemented** — 3 loop pairs → 1; line count corrected below |
+| **D-2** | Duplication | `_currentSupply()` byte-identical in two rules; token validation near-identical | ✅ **implemented** — shared base, −12 gas |
+| **D-3** | Duplication | detect-then-`require` `_transferred` pair repeated in 8 rules | 8 copies |
+| **D-4** | Duplication | `checkSpender` setter machinery duplicated, though the flag lives in the shared parent | ✅ **implemented** — one definition site |
+| **D-5** | Duplication | `isVerified` and `_isListedInAnyChild` have identical bodies | ✅ **implemented** — −124 bytes, +27 gas/call |
+| **E-1** | Convention | 16 `internal` functions lack `virtual`, against the project's own rule — including an access-control hook | ✅ **implemented** — 0 gas, 0 remaining |
+| **E-2** | Convention | `canTransfer` is the only non-`virtual` view in `RuleTransferValidation` | ✅ **implemented** — plus its ERC-7943 twin; ~55 more found, see note |
+| **E-3** | Convention | 27 public mutating functions lack `virtual`; siblings disagree, and it already forced a documented workaround | ✅ **implemented** — 0 gas, 0 remaining |
+| **F-1** | Weird | Sanctions rule asks the oracle whether `address(0)` is sanctioned on every mint and burn | ✅ **implemented** — 2 830 gas/mint (+96 on transfers), removes the dependency |
+| **F-2** | Weird | Sanctions `From` path skips the direct check entirely when the oracle is unset | ✅ **implemented** — the sketched fix was wrong, see note |
+| **F-3** | Weird | Dead condition `to != address(0)` in `RuleIdentityRegistryBase` | ✅ **implemented** — 49 gas, comment corrected |
+| **F-4** | Weird | `_transferHash` assembly matches neither `abi.encode` nor `abi.encodePacked`, but the comment says "packed" | ✅ **implemented (option 1)** — comment fixed, preimage documented + pinned |
+| **F-5** | Weird | Batch add **reverts** on `address(0)`, contradicting `CLAUDE.md` / `AGENTS.md` and the functions' own NatSpec | ✅ **implemented** — docs corrected, code unchanged |
+| **F-6** | Weird | `RuleMintAllowance.canTransfer` unconditionally returns `true` | ✅ **option 1 implemented** — docs + test, no code change |
+| **F-7a** | Nit | Empty `INTERNAL FUNCTIONS` banner in `IdentityRegistryWhitelistBase` | ✅ **implemented** |
+| **F-7b** | Nit | `VersionModule.version()` is `view` but returns a constant; could be `pure` | ✅ **implemented** — ABI `stateMutability` changes |
+| **F-7c** | Nit | `approveAndTransferIfAllowed` pre-checks `allowance` before `safeTransferFrom` | ~2 600 gas for a better error — keep |
+
+---
+
+## A. Loop gas
+
+### A-1. `++i` — already done everywhere, and do not add `unchecked` ✅
+
+All 13 `for` loops in `src/` already use the pre-increment form. There is nothing to change:
+
+| File | Line |
+|---|---|
+| `src/rules/operation/abstract/RuleMintAllowanceBase.sol` | 125 |
+| `src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol` | 140 |
+| `src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol` | 42, 71 |
+| `src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol` | 268, 272, 280 |
+| `src/rules/validation/abstract/base/RuleERC2980Base.sol` | 349, 387 |
+| `src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol` | 48, 70, 109, 131 |
+
+**Important follow-on:** the usual companion micro-optimisation, `unchecked { ++i; }`, is **obsolete for this project and should not be introduced.** Since Solidity 0.8.22 the compiler automatically elides the overflow check on a loop counter whose condition provably bounds it, and `foundry.toml` pins `solc = "0.8.34"`. Adding `unchecked` blocks now would buy zero gas while re-introducing a class of bug the compiler is currently preventing. If a reviewer or a linter suggests it, this is the reason to decline.
+
+Caching `array.length` in a loop condition is likewise not worth changing here: the arrays are `calldata` or `memory`, where `.length` is a `calldataload`/`mload`, not an `SLOAD`. `RuleWhitelistWrapperBase:266` caches `rulesCount()` — that one *is* worth caching, and it already is.
+
+### A-2. Redundant per-iteration rescan in the wrapper — `RuleWhitelistWrapperBase.sol:260-291` — ✅ IMPLEMENTED
+
+> **Status: fixed.** Implemented exactly as sketched below. Measured saving: **~85 gas per child scanned**, ~1% of the ~8.8k per-child cost — the external `STATICCALL` dominates, so this is a small win. Both suites pass (666 + 18); branch coverage of the file stays at 100% (19/19). Regression test: `testDetectTransferRestrictionOkWhenAddressListedInSeveralChildRules`.
+
+```solidity
+for (uint256 i = 0; i < rulesLength; ++i) {
+ bool[] memory isListed = IAddressList(rule(i)).areAddressesListed(targetAddress);
+ for (uint256 j = 0; j < targetAddress.length; ++j) {
+ if (isListed[j]) { result[j] = true; }
+ }
+ // Break early if all listed
+ bool allListed = true;
+ for (uint256 k = 0; k < result.length; ++k) { // <-- full rescan, every iteration
+ if (!result[k]) { allListed = false; break; }
+ }
+ if (allListed) { break; }
+}
+```
+
+The third loop re-derives "are they all resolved?" from scratch on every child rule, although the second loop just observed exactly which entries changed. A counter makes the check O(1) and removes the loop:
+
+```solidity
+uint256 unresolved = targetAddress.length;
+for (uint256 i = 0; i < rulesLength; ++i) {
+ bool[] memory isListed = IAddressList(rule(i)).areAddressesListed(targetAddress);
+ for (uint256 j = 0; j < targetAddress.length; ++j) {
+ if (isListed[j] && !result[j]) {
+ result[j] = true;
+ --unresolved;
+ }
+ }
+ if (unresolved == 0) { break; }
+}
+```
+
+Targets are 1–3 and rules are meant to stay bounded, so the absolute saving is small — but this is the wrapper's hot path, executed on every transfer, and the rewrite is strictly simpler than what it replaces.
+
+**Why `!result[j]` is load-bearing.** The guard is the whole correctness argument for the counter form, so it is worth stating explicitly rather than leaving it to be read out of the code. An address listed in more than one child would otherwise decrement `unresolved` once per listing, driving the counter to zero early and breaking out of the scan before a later child could resolve a *different* target — rejecting a valid transfer. That is the case the added regression test constructs: `ADDRESS1` in children 1 and 2, `ADDRESS2` only in child 3.
+
+### A-3. `areAddressesListed` takes `memory` where `calldata` would do
+
+`src/rules/interfaces/IAddressList.sol:84` and `src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol:138` both declare `address[] memory`. The only caller is cross-contract (`RuleWhitelistWrapperBase:271`), and no internal caller exists in `src/` or `test/`, so `external` + `calldata` is available and would skip a calldata→memory copy on every wrapper child-rule call. The ABI selector `areAddressesListed(address[])` is unaffected, so `AddressListInterfaceId` stays valid.
+
+---
+
+## B. Storage reads that should be local variables
+
+### B-1. The slot is written, then read back to populate the event — 6 sites — ✅ PARTLY IMPLEMENTED (2 of 6)
+
+```solidity
+// RuleConditionalTransferLightApprovalBase.sol:56-57
+approvalCounts[transferHash] += 1;
+emit TransferApproved(from, to, value, approvalCounts[transferHash]); // re-reads what was just stored
+```
+
+| File | Lines | Function |
+|---|---|---|
+| `RuleConditionalTransferLightApprovalBase.sol` | 56-57 | `approveTransfer` |
+| `RuleConditionalTransferLightApprovalBase.sol` | 70-71 | `cancelTransferApproval` |
+| `RuleConditionalTransferLightApprovalBase.sol` | 139-140 | `_transferred` |
+| `RuleConditionalTransferLightMultiTokenBase.sol` | 341-342 | `_approveTransfer` |
+| `RuleConditionalTransferLightMultiTokenBase.sol` | 359-360 | `_cancelTransferApproval` |
+| `RuleConditionalTransferLightMultiTokenBase.sol` | 381-382 | `_transferred` |
+
+In the cancel/consume cases the value is *already in a local* (`count - 1`) and is thrown away in favour of re-reading it. Worth noting that `resetApproval` (`ApprovalBase.sol:92-96`) already does this correctly with its `cleared` local — so the right pattern is present in the same file, three lines away from two of the wrong ones.
+
+> **Status: partly implemented — and the original finding was wrong for 4 of the 6 sites.**
+>
+> Only the two `+= 1` sites (`approveTransfer`, `_approveTransfer`) were changed. Measurement showed the four `= count - 1` sites were **already free**: the Yul optimizer forwards the freshly-stored value to the subsequent load, so the "re-read" costs nothing, and introducing an explicit local there makes the code *slower*.
+
+**How that was established.** A micro-benchmark with each variant in its own single-function contract — so the selector, and therefore the dispatch depth, is identical; comparing variants inside one contract attributes binary-search dispatch differences to the function body and produced a misleading result on the first attempt:
+
+| Pattern | Old | New | Delta |
+|---|---|---|---|
+| `+= 1` then re-read | 2 744 | 2 635 | **−109 gas** |
+| `= count - 1` then re-read | 2 663 | 2 675 | **+12 gas** |
+
+Confirmed on the real contracts, using two `ThreatModel` tests measured before and after:
+
+| Variant | `test_CTL2_EngineKeyedApprovalIsSharedAcrossTokens` | `test_CTL2_MultiTokenApprovalKeyMismatchLeavesStaleApproval` |
+|---|---|---|
+| Baseline (unmodified) | 3 281 318 | 4 063 854 |
+| All 6 sites changed | 3 280 412 (−906) | 4 062 950 (−904) |
+| **Increment sites only (shipped)** | **3 278 419 (−2 899)** | **4 060 955 (−2 899)** |
+
+Changing the four decrement sites gave back ~1 995 gas of the saving. Increment-only is more than 3× better than the blanket fix this finding originally recommended.
+
+**Generalisable lesson for the rest of this report:** "write then re-read" is only a defect when the stored expression is a read-modify-write of the slot itself (`x += 1`). Where the value already exists as a local, the optimizer handles it and hand-caching is a pessimisation. The same caveat applies to **B-2** and **B-3** — those are repeated reads with *external calls in between*, which the optimizer cannot forward across, so they should still pay off; but they should be measured, not assumed.
+
+**Test gap found while implementing.** Nothing in the suite asserted the `TransferApproved` payload — only `approvedCount()` was ever checked, on both rules. A change to how the emitted count is derived was therefore invisible to the tests. Closed with `testApproveTransfer_EmitsPostIncrementCount` and `test_ApproveTransferEmitsPostIncrementCount`, which approve the same transfer twice and require the event to report 1 then 2. Worth extending to the other approval events (`TransferExecuted`, `TransferApprovalCancelled`, `TransferApprovalReset`), which are equally unasserted — a rule whose entire off-chain interface is these events should not have them unpinned.
+
+### B-2. `sanctionsList` read up to five times per check — `RuleSanctionsListBase.sol:141-183` — ✅ IMPLEMENTED
+
+```solidity
+if (address(sanctionsList) != address(0)) { // read 1
+ if (sanctionsList.isSanctioned(from)) { // read 2
+ } else if (sanctionsList.isSanctioned(to)) { // read 3
+```
+
+and `_detectTransferRestrictionFrom` adds two more before delegating into the function above, which reads it again. Cache once:
+
+```solidity
+ISanctionsList oracle = sanctionsList;
+if (address(oracle) == address(0)) { return uint8(REJECTED_CODE_BASE.TRANSFER_OK); }
+```
+
+> **Status: fixed.** Unlike B-1 this pays off as predicted — the reads are separated by external calls, which the optimizer cannot forward across. Measured on `RuleSanctionsList` with an oracle configured, each path in its own transaction after an identical warm-up:
+>
+> | Path | Before | After | Saving |
+> |---|---|---|---|
+> | `detectTransferRestriction`, nobody sanctioned | 3 554 | 3 335 | **−219** |
+> | `detectTransferRestriction`, `from` sanctioned | 4 506 | 4 408 | −98 |
+> | `detectTransferRestrictionFrom`, nobody sanctioned | 4 901 | 4 581 | **−320** |
+> | `detectTransferRestrictionFrom`, spender sanctioned | 4 601 | 4 503 | −98 |
+> | either, oracle unset | 1 462 / 1 554 | 1 455 / 1 547 | −7 |
+>
+> ~98 gas per avoided reload, matching a warm `SLOAD`. The two clean paths — the ones every compliant transfer takes — save the most, because they make the most oracle calls.
+>
+> **Caching is provably safe here, not merely probably safe:** both functions are `view`, so `isSanctioned` is reached by `STATICCALL`, which cannot write this contract's storage. `sanctionsList` therefore cannot change between the guard and the calls. That reasoning is now a comment in the code.
+>
+> **One reload was deliberately left in place.** On the `transferFrom` path, `_detectTransferRestrictionFrom` delegates to `_detectTransferRestriction`, which reads the slot again — roughly 100 gas that a shared `_screen(oracle, from, to)` helper would remove. It was not done: the From path calling the direct hook is how every rule in the library composes its two checks, and routing around it through a private helper would mean a subclass overriding `_detectTransferRestriction` no longer affects `transferFrom`. Preserving that dispatch is worth 100 gas, especially given **E-1** proposes making these hooks `virtual` in the first place.
+
+### B-3. `identityRegistry` read up to five times, and the guard is evaluated twice — `RuleIdentityRegistryBase.sol:187-251` — ✅ IMPLEMENTED
+
+`_detectTransferRestriction` reads the slot at 197, 206 and 212. `_detectTransferRestrictionFrom` reads it at 232 and 246, then calls `_detectTransferRestriction`, which repeats all three. The same call also re-tests `address(identityRegistry) == address(0)` and `to == address(0)` in both functions. Hoisting the registry into a local and passing it down removes both the repeated `SLOAD`s and the duplicated guard.
+
+> **Status: fixed (the caching half).** Measured per path, each in its own transaction after an identical warm-up:
+>
+> | Path | Before | After | Delta |
+> |---|---|---|---|
+> | transfer, receiver-only — the ERC-3643 default | 2 773 | 2 660 | **−113** |
+> | transfer, `checkSender` on | 3 880 | 3 661 | **−219** |
+> | `transferFrom`, receiver-only | 3 319 | 3 211 | **−108** |
+> | `transferFrom`, both flags on | 5 591 | 5 271 | **−320** |
+> | mint | 2 772 | 2 659 | **−113** |
+> | registry unset | 1 474 | 1 479 | **+5** |
+>
+> ~110 gas per avoided reload. Same provable-safety argument as **B-2**: both functions are `view`, so `isVerified` is reached by `STATICCALL` and cannot write `identityRegistry`; the reasoning is a comment in the code.
+>
+> **The unset-registry path is 5 gas worse**, deterministically (measured three times). Loading the slot into a typed local before comparing costs a couple of stack operations that comparing in place did not. Accepted: it is the path where the rule is switched off entirely and does nothing else, against 108–320 gas saved on every path where it actually screens. Recorded rather than rounded away, because **B-1** showed this exact kind of "obvious" caching can be a net loss.
+>
+> **The duplicated guard was NOT removed** — only the repeated `SLOAD`s. `_detectTransferRestrictionFrom` still delegates to `_detectTransferRestriction`, which re-tests both the null registry and `to == address(0)`. Removing that needs a shared helper taking the registry as a parameter, which would stop a subclass's override of `_detectTransferRestriction` from applying to `transferFrom`. Same trade-off as B-2, resolved the same way — and now more valuable, since **E-1** made those hooks `virtual` precisely so they *can* be overridden.
+
+### B-4. `contains()` followed by `add()`/`remove()` — double set lookup, 8 sites — ✅ IMPLEMENTED
+
+```solidity
+// RuleAddressSet.sol:88-91
+require(targetAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
+require(!_isAddressListed(targetAddress), RuleAddressSet_AddressAlreadyListed()); // lookup 1
+_addAddress(targetAddress); // lookup 2, return value discarded
+```
+
+`EnumerableSet.add` / `.remove` already return "did this change anything", which is exactly what the preceding `require` is testing. Using the return value halves the lookups:
+
+```solidity
+require(_listedAddresses.add(targetAddress), RuleAddressSet_AddressAlreadyListed());
+```
+
+Sites: `RuleAddressSet.sol:89/90` and `102/103`; `RuleERC2980Base.sol:135/136`, `150/151`, `190/191`, `205/206`; `IdentityRegistryWhitelistBase.sol:84/85` and `94/95`. The first lookup is a cold `SLOAD` (~2 100 gas) on the common path. This is the largest single gas item in the review.
+
+Doing this cleanly requires the internal `_addAddress`/`_removeAddress` helpers to forward the bool, which is a small signature change to `RuleAddressSetInternal.sol:84-94` and `RuleERC2980Internal.sol:83-93/144-154`.
+
+> **Status: fixed** at all eight sites — `RuleAddressSet` (2), `RuleERC2980Base` (4), `IdentityRegistryWhitelistBase` (2). The six internal helpers now forward `EnumerableSet`'s result; no override existed anywhere, so the signature change was contained.
+>
+> **Correction: this finding overstated the saving by roughly 7×.** It claimed "~2 100 gas cold, per call", reasoning that the first lookup is a cold `SLOAD`. That is true but irrelevant — *whichever* access happens first pays the cold price, so removing one of two accesses to the same slot removes the **warm** one, not the cold one. The measured saving is the warm `SLOAD` plus the mapping-slot `keccak256` and the internal call overhead:
+>
+> | Call | Before | After | Saving |
+> |---|---|---|---|
+> | `addAddress` | 76 348 | 76 060 | **−288** |
+> | `removeAddress` | 4 176 | 3 891 | **−285** |
+> | `addWhitelistAddress` (ERC-2980) | 76 282 | 75 994 | **−288** |
+> | `removeFrozenlistAddress` (ERC-2980) | 4 154 | 3 868 | **−286** |
+> | `registerIdentity` | 76 617 | 76 328 | **−289** |
+>
+> Consistently ~288 gas. Still worth doing — it is free, and it removes the possibility of the guard and the mutation disagreeing — but it is **not** the largest gas item in this review, as the summary table claimed. On an add the saving is 0.4% of the call, which is dominated by the ~20k cold `SSTORE`; on a remove it is ~7%.
+>
+> **Behaviour is identical, including error identity.** `require(_addAddress(x), AddressAlreadyListed())` reverts on exactly the inputs the old `require(!_isAddressListed(x), …)` did: `add` returns `false` for a duplicate without touching storage, and the zero-address guard still runs first, so `address(0)` still yields `ZeroAddressNotAllowed` rather than the duplicate error. A revert undoes the insertion in the cases where one happened. 700 + 18 tests pass unchanged — including `testAddAddressTwiceToTheWhitelist`, `testCannotAddAddressZeroToTheWhitelist` and the ERC-2980 equivalents, which are precisely the assertions that would break if the ordering had shifted.
+
+
+
+---
+
+## C. Missing events
+
+### C-1. `RuleMaxTotalSupply` is deployed silently, while `RuleChainlinkPoR` is not — ✅ IMPLEMENTED
+
+```solidity
+// RuleMaxTotalSupplyBase.sol:36-40
+constructor(address tokenContract_, uint256 maxTotalSupply_) {
+ _validateTokenContract(tokenContract_);
+ tokenContract = ITotalSupply(tokenContract_); // no TokenContractUpdated
+ maxTotalSupply = maxTotalSupply_; // no MaxTotalSupplyUpdated
+}
+```
+
+Both events exist (`RuleMaxTotalSupplyInvariantStorage.sol:38,43`) and both setters emit them (`:63-76`). Only the constructor is silent, so an indexer that follows `MaxTotalSupplyUpdated` sees the cap appear from nowhere at the first `setMaxTotalSupply` and has no value at all for a rule that is never reconfigured — the normal case for a supply cap.
+
+The contrast makes this unambiguous: `RuleChainlinkPoRBase.sol:74-83` routes its constructor through `_setReservesFeed` / `_setTokenMetadata` / `_setMaxStalenessSeconds`, so all three of its config values are emitted at deployment. Two rules, same release, same concern, opposite behaviour. Apply the `RuleChainlinkPoR` pattern:
+
+```solidity
+constructor(address tokenContract_, uint256 maxTotalSupply_) {
+ _setTokenContract(tokenContract_);
+ _setMaxTotalSupply(maxTotalSupply_);
+}
+```
+
+> **Status: fixed**, exactly as sketched. The two new internal helpers are shared with the public setters, so the event fires on every assignment rather than only on later ones.
+
+
+
+### C-2. `checkSpender`'s initial value is never announced — ✅ IMPLEMENTED
+
+```solidity
+// RuleWhitelistBase.sol:32-37 (identical in RuleWhitelistWrapperBase.sol:37-42)
+constructor(address forwarderIrrevocable, bool checkSpender_, bool allowMintBurn) ... {
+ checkSpender = checkSpender_; // silent
+ _setAllowMintBurn(allowMintBurn, allowMintBurn); // emits AllowMintUpdated + AllowBurnUpdated
+}
+```
+
+Adjacent lines, opposite treatment. Three booleans are configured at deployment; two are evented and one is not. `CheckSpenderUpdated` exists (`RuleWhitelistInvariantStorage.sol:61`) and both setters emit it. Route the constructor through `_setCheckSpender` and emit — or better, fold it into a `_setCheckSpender` that emits, which also fixes D-4.
+
+> **Status: fixed** by the second option — the emit moved from the public setter into `_setCheckSpender`, which both constructors now call. The public setter still emits exactly once, pinned by its own test, because moving an emit into a helper the setter also calls is precisely how you accidentally double-emit. This does **not** fix D-4: the setter, the helper, the modifier and the authorization hook are still duplicated across the two whitelist bases; only the event placement changed, in both copies.
+
+
+
+### C-3. `RuleIdentityRegistry` constructor emits the flags but not the registry — ✅ IMPLEMENTED
+
+```solidity
+// RuleIdentityRegistryBase.sol:63-71
+if (identityRegistry_ != address(0)) {
+ identityRegistry = IIdentityRegistryVerified(identityRegistry_); // no IdentityRegistryUpdated
+}
+checkSender = checkSender_;
+checkSpender = checkSpender_;
+emit IdentityCheckSenderUpdated(checkSender_);
+emit IdentityCheckSpenderUpdated(checkSpender_);
+```
+
+Same shape as C-2: two of three config values are emitted. `IdentityRegistryUpdated` exists and both `setIdentityRegistry` and `clearIdentityRegistry` emit it — only the constructor doesn't. Since the registry address is *the* thing this rule is parameterised by, a rule deployed with its registry set and never reconfigured has no on-chain event trail describing what it screens against.
+
+`RuleSanctionsListBase.sol:32-38` gets this right: it calls `_setSanctionListOracle`, which emits.
+
+> **Status: fixed.** `IdentityRegistryUpdated` is now emitted at construction — but **only when a registry is actually assigned**. A zero argument leaves the default untouched, and emitting `IdentityRegistryUpdated(address(0))` there would be indistinguishable from a deliberate `clearIdentityRegistry()`, turning a non-event into a state change for anyone replaying the log. That gives the library a single rule — *every value actually assigned is announced* — which happens to reproduce `RuleSanctionsListBase`'s existing behaviour exactly.
+
+
+
+### C-4. Batch events describe the input, not the effect — and the effect is computed then thrown away — ✅ IMPLEMENTED
+
+```solidity
+// RuleAddressSet.sol:63-66
+function addAddresses(address[] calldata targetAddresses) public onlyAddressListAdd {
+ _addAddresses(targetAddresses); // returns (added, skipped) — discarded
+ emit AddAddresses(targetAddresses); // echoes the input
+}
+```
+
+Every one of the six batch internals computes `(added, skipped)` or `(removed, skipped)` in its loop, and **every single caller discards the result**:
+
+| Internal | Caller that discards it |
+|---|---|
+| `RuleAddressSetInternal._addAddresses` | `RuleAddressSet.sol:64` |
+| `RuleAddressSetInternal._removeAddresses` | `RuleAddressSet.sol:76` |
+| `RuleERC2980Internal._addWhitelistAddresses` | `RuleERC2980Base.sol:110` |
+| `RuleERC2980Internal._removeWhitelistAddresses` | `RuleERC2980Base.sol:120` |
+| `RuleERC2980Internal._addFrozenlistAddresses` | `RuleERC2980Base.sol:165` |
+| `RuleERC2980Internal._removeFrozenlistAddresses` | `RuleERC2980Base.sol:175` |
+
+So the contract pays for the counter arithmetic on every iteration and then emits an event that cannot answer the one question an indexer has: *which of these addresses actually changed state?* A batch of 100 addresses of which 99 were already listed emits the same event as a batch of 100 fresh ones.
+
+Two ways out, and either is an improvement over the current state:
+- **Use them:** `emit AddAddresses(targetAddresses, added, skipped)` — the numbers are already in hand, so the only extra cost is the log data.
+- **Drop them:** if the counts are genuinely not wanted, make the internals `void` and stop paying for the increments.
+
+Leaving the code as-is — computing, discarding, and emitting something less informative — is the one option with no argument in its favour.
+
+> **Status: fixed by the first option — the counters are now emitted.** All six batch events gained them:
+> `AddAddresses(address[], uint256 added, uint256 skipped)` and its `Remove` counterpart in `IAddressList`,
+> plus the four `RuleERC2980` whitelist/frozenlist equivalents. `added + skipped` always equals the input
+> length, which the fuzz test asserts.
+>
+> **This is a breaking change to the event ABI**, and it is the reason to make it now rather than later:
+> six signatures change, so `topic0` changes with them —
+> `AddAddresses(address[])` was `0xc81f47d2…`, `AddAddresses(address[],uint256,uint256)` is `0x986167d3…`.
+> Any indexer filtering on the old topic stops matching. v0.5.0 is unreleased, so nothing downstream is
+> relying on the old shape yet; after release this would need a major-version discussion instead.
+>
+> **Cost: +572 gas per batch call**, measured before and after on the real contracts with every dependent
+> file reverted for the baseline (997 155 → 997 727 on a 20-address add; 55 423 → 55 995 on the remove).
+> Note it is **constant, not per element** — two extra 32-byte log words — so it is 0.06% of a 20-address
+> add, which is dominated by cold `SSTORE`s, and 1.0% of the cheaper remove. The counters themselves were
+> already being computed, so nothing new is spent in the loop.
+>
+> **Verified, not assumed.** `test/Events/BatchEventEffect.t.sol` (9 tests) pins the case the input array
+> could never express — a batch that is *partly* or *wholly* a no-op — for both the shared `RuleAddressSet`
+> machinery and `RuleERC2980`'s separate copy of the same loops. A fuzz case asserts `added + skipped ==
+> input.length` and that `skipped` equals what was already present.
+>
+> **A pre-existing test defect surfaced.** `RuleWhitelistRemove.t.sol` contained three bare
+> `emit IAddressList.AddAddresses(...)` / `RemoveAddresses(...)` statements with **no `vm.expectEmit` before
+> them** — they emitted an event from the test contract and asserted nothing at all. The compiler flagged them
+> only because the arity changed. Those three are now real assertions, and the most useful of them checks a
+> batch of 3 removals where only 2 were present: `(removed = 2, skipped = 1)` — exactly the information this
+> finding was about.
+>
+> **Two further instances were found later and are also fixed.** The same defect survived on two *singular*
+> events in the same file (`emit IAddressList.RemoveAddress(ADDRESS1);` at `:48` and `:95`). Their arity did
+> not change, so the compiler never surfaced them and this finding's original sweep did not reach them. They
+> needed different fixes: the first is a successful removal, so it became a real assertion
+> (`vm.expectEmit` → `emit` → `vm.prank` → call, matching the file's other cases); the second sits inside a
+> test that expects a revert, so **no event is ever emitted** and the statement could not be turned into an
+> assertion at all — it was removed, with a comment saying why. Neither is in C-4's scope, both being
+> single-address events, but the file is now genuinely free of bare emits. The new assertion was
+> mutation-checked: expecting `ADDRESS2` where the contract emits `ADDRESS1` fails with
+> `RemoveAddress param mismatch at targetAddress`.
+
+---
+
+### Testing note for C-1 / C-2 / C-3
+
+The whole suite passed before any of these fixes, and passed again immediately after — **nothing anywhere asserted constructor events**, in either direction. That is the same gap F-1 had. `test/Events/ConstructorEvents.t.sol` now covers all three, matching on `topic0` rather than `vm.expectEmit` so that a wrong *number* of emissions is caught as well as a missing one — the failure mode C-2's fix could plausibly introduce. Four of its seven tests fail against the pre-fix code, verified by reverting each change and re-running. It also pins `RuleChainlinkPoR`, which was already correct: it is the rule the others were made to match, so the convention should not be able to regress from that side either.
+
+
+## D. Duplication
+
+### D-1. `RuleERC2980Internal` is `RuleAddressSetInternal`, pasted twice — ~190 lines — ✅ IMPLEMENTED
+
+`src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol` implements the whitelist and the frozenlist as two independent copies of the same `EnumerableSet` machinery, which is itself a third copy of `RuleAddressSetInternal.sol`. Line-for-line:
+
+| `RuleAddressSetInternal` | `RuleERC2980Internal` (whitelist) | `RuleERC2980Internal` (frozenlist) |
+|---|---|---|
+| `_addAddresses` :41 | `_addWhitelistAddresses` :44 | `_addFrozenlistAddresses` :105 |
+| `_removeAddresses` :67 | `_removeWhitelistAddresses` :66 | `_removeFrozenlistAddresses` :127 |
+| `_addAddress` :84 | `_addWhitelistAddress` :83 | `_addFrozenlistAddress` :144 |
+| `_removeAddress` :92 | `_removeWhitelistAddress` :91 | `_removeFrozenlistAddress` :152 |
+| `_isAddressListed` :109 | `_isWhitelisted` :165 | `_isFrozen` :182 |
+| `_listedAddressCount` :100 | `_whitelistCount` :173 | `_frozenlistCount` :190 |
+
+The bodies are identical modulo the set variable and the error name — including the zero-address comment, which appears three times in slightly different wording. `RuleERC2980Base.sol:109-208` then duplicates the eight public wrappers in the same 2×4 pattern.
+
+The structural fix is a library or an internal helper parameterised by the set:
+
+```solidity
+function _addTo(EnumerableSet.AddressSet storage set, address[] calldata toAdd)
+ internal returns (uint256 added, uint256 skipped)
+```
+
+That collapses three implementations into one and makes B-4 a single-site fix instead of a ten-site one. It is the highest-value refactor in this review, and also the most invasive — the sets are `private`, so this is a deliberate encapsulation choice that would have to be revisited.
+
+> **Status: fixed** — with two corrections to the finding and one design constraint it did not anticipate.
+>
+> **Correction 1: the "~190 lines" figure counted NatSpec.** The actual duplicated *logic* was ~90 lines (two redundant copies of ~45). The two files were 48 and 90 code lines before; they are now 40 and 69, with a 31-line shared library — so ~29 net lines removed, and more importantly three copies of the two loops became one.
+>
+> **Correction 2: only the loops were worth sharing.** `add` / `remove` / `contains` / `length` on a single address are one-line delegations to `EnumerableSet`; routing them through a library adds indirection without removing duplication. They stay where they are.
+>
+> **The constraint the finding missed: each rule reverts with its own custom error.** `RuleAddressSet_ZeroAddressNotAllowed` vs `RuleERC2980_ZeroAddressNotAllowed`, per the codebase-wide one-namespace-per-rule convention. The sketched `_addTo(set, toAdd)` signature cannot express that, and the obvious workarounds are both bad:
+> - a single shared error changes revert data that tests and integrators already depend on, and breaks the convention;
+> - returning a "zero found" flag for the caller to check makes the guard *optional in practice* — a caller that forgets silently lists `address(0)`, the exact outcome the guard exists to prevent.
+>
+> The shipped signature passes the guard as an `internal pure` function pointer:
+> ```solidity
+> function addBatch(EnumerableSet.AddressSet storage set, address[] calldata toAdd,
+> function(address) internal pure guard) internal returns (uint256 added, uint256 skipped)
+> ```
+> A required parameter cannot be forgotten, and each rule keeps its own error. Both `RuleERC2980_ZeroAddressNotAllowed` and `RuleAddressSet_ZeroAddressNotAllowed` assertions still pass unchanged.
+>
+> **Storage layout verified identical**, which is the one thing this refactor could have broken silently. Compared per-slot from the compiled artifacts across `RuleWhitelist`, `RuleERC2980`, `RuleBlacklist`, `RuleReceiverWhitelist`, `RuleSpenderWhitelist` and `IdentityRegistryWhitelist` — all identical. That took three attempts: the first two comparisons were vacuous (`forge inspect` silently produced empty output because a plain `forge build` drops the storage-layout artifact), and reported "identical" for two empty files. Worth stating because a vacuous pass on exactly this check is how a layout break ships.
+>
+> **Cost: ~34 gas per entry on batch adds** (996 496 → 997 184 for a 20-address `addAddresses`, +688 on ~1M), ~4 per entry on removes, from the function-pointer's indirect jump. This is an **operator path, not a holder path** — batch listing is an admin operation dominated by ~22k cold `SSTORE` per address, so the overhead is 0.07% of the transaction. `RuleERC2980`'s runtime bytecode shrinks by 200 bytes (two copies collapsed); `RuleWhitelist`'s grows by 62 (one copy, plus the indirection).
+>
+> **Coverage 100%** on all three files (library 13/13 branches, `RuleAddressSetInternal` 9/9, `RuleERC2980Internal` 21/21), and 700 + 18 tests pass. No new tests: the refactor is behaviour-preserving and the batch paths were already covered — including, since **F-5**, the ERC-2980 zero-address rejection that previously had none.
+
+
+
+### D-2. `_currentSupply()` is byte-identical in two rules — ✅ IMPLEMENTED
+
+`RuleChainlinkPoRBase.sol:328-335` and `RuleMaxTotalSupplyBase.sol:157-164` are the same function, differing only in a doc comment. Their token-validation logic is near-identical too (`RuleChainlinkPoRBase._setTokenMetadata:238-262` vs `RuleMaxTotalSupplyBase._validateTokenContract:135-142`): same zero check, same `code.length` check, same `try totalSupply()` probe, different error names.
+
+The two rules already share a concept — "read a supply figure from a token I do not control, without ever reverting a view" — and `CLAUDE.md` documents that they share a hazard (one instance per protected token) and a deployment precondition (EIP-6780). A shared `TokenSupplyOracleBase` would give that concept one home. The blocker is the error names and the constant names (`CODE_TOTAL_SUPPLY_UNAVAILABLE` vs `CODE_SUPPLY_ORACLE_UNAVAILABLE`), which differ **only because `HelperContract` in the test suite inherits both invariant-storage contracts and identical identifiers would clash** — a test-harness constraint driving production naming. Worth revisiting on that basis alone.
+
+> **Status: fixed** as `TokenSupplyReader` (`src/rules/validation/abstract/core/`). Two design decisions are worth recording, because the naive version of this refactor is worse than the duplication.
+>
+> **The base holds no storage.** The obvious shape — a base declaring `ITotalSupply public tokenContract` — would reorder every inheriting rule's slots: `RuleChainlinkPoR` would see `tokenContract` move ahead of `reservesFeed`. Instead each rule keeps its own variable and implements a `_supplyToken()` hook, the template-method pattern this codebase already uses for `_authorize*`. **Storage layout verified identical** for `RuleMaxTotalSupply`, `RuleChainlinkPoR` and both Ownable2Step variants, compared per-slot from the compiled artifacts with both sides confirmed non-empty.
+>
+> **Validation is deliberately NOT shared.** Both rules check non-zero / has-code / `totalSupply()`-callable, but each raises its own named error for each of the three failures. Only the non-trivial part — the `try/catch` probe — moved into the base as `_probeTotalSupplyCallable`, which returns a `bool`; each rule composes it with its own `require`. Collapsing the three `require`s into one boolean helper would trade three named configuration diagnostics for a couple of saved lines, which is the wrong trade for a library whose configuration errors are its main operator-facing signal.
+>
+> **The finding's premise about the constant names turned out not to matter.** `CODE_TOTAL_SUPPLY_UNAVAILABLE` vs `CODE_SUPPLY_ORACLE_UNAVAILABLE` are declared in the two *invariant-storage* contracts, which this refactor does not touch — the shared base returns `(bool, uint256)` and each rule maps `false` onto its own code. So the test-harness naming constraint never had to be revisited; it simply is not on the path.
+>
+> **Gas: 12 gas cheaper on both mint read paths** (`RuleChainlinkPoR` 5 966 → 5 954, `RuleMaxTotalSupply` 2 460 → 2 448). The hook inlines and removes an intermediate stack shuffle the old `ITotalSupply token = tokenContract;` local produced. A shared abstraction that is also marginally faster is an unusually clean outcome; it is small enough not to matter either way.
+>
+> **Coverage:** 100% branches on the new file (6/6). Line coverage reads 10/11 and function coverage 2/3 only because the abstract `_supplyToken()` declaration has no body and cannot be executed — not a gap. 700 + 18 tests pass, no new tests needed: the refactor is behaviour-preserving and both rules' supply paths, including the `catch` branches, were already covered.
+
+
+
+### D-3. The detect-then-`require` pair, in 8 rules
+
+```solidity
+function _transferred(address from, address to, uint256 value) internal view virtual override {
+ uint8 code = _detectTransferRestriction(from, to, value);
+ require(code == uint8(REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleXxx_InvalidTransfer(address(this), from, to, value, code));
+}
+```
+
+Present in nine rules: `RuleWhitelistShared.sol:190,201`, `RuleReceiverWhitelistBase.sol:159,174`, `RuleBlacklistBase.sol:158,173`, `RuleSanctionsListBase.sol:191,206`, `RuleIdentityRegistryBase.sol:256,267`, `RuleERC2980Base.sol:476,487`, `RuleChainlinkPoRBase.sol:415,430`, `RuleMaxTotalSupplyBase.sol:212,227`, `RuleSpenderWhitelistBase.sol:120,131`. Sixteen of the seventeen functions follow the identical shape; the seventeenth, `RuleSpenderWhitelist._transferred`, is a deliberate no-op.
+
+The only thing that varies is the custom error selector. That is a real constraint (a per-rule error is better for integrators than a generic one), so this is *justified* duplication rather than accidental — but it could still be collapsed with a `virtual` hook that returns the revert data, or by having each rule supply its errors to a shared enforcement helper. Recorded as a deliberate trade-off to re-examine, not a defect.
+
+### D-4. `checkSpender` machinery duplicated across the two whitelist bases — ✅ IMPLEMENTED
+
+The `checkSpender` *state variable* lives in the shared parent `RuleWhitelistShared.sol:23`. Everything that operates on it is duplicated in the two children:
+
+| Member | `RuleWhitelistBase` | `RuleWhitelistWrapperBase` |
+|---|---|---|
+| `setCheckSpender` | :48-51 | :65-68 |
+| `_setCheckSpender` | :93-95 | :98-100 |
+| `_authorizeCheckSpenderManager` | :101 | :108 |
+| `onlyCheckSpenderManager` modifier | :80-83 | :48-51 |
+
+All four are verbatim identical. The parent already demonstrates the right pattern for the *other* two flags: `allowMint`/`allowBurn` keep their setters, their `onlyMintBurnManager` modifier and their `_authorizeMintBurnManager` hook in `RuleWhitelistShared` itself (`:46-49, 105-117, 185`). Moving the `checkSpender` quartet up next to them removes the duplication and makes C-2 a one-line fix.
+
+> **Status: fixed.** All four moved into `RuleWhitelistShared`, each placed beside its `allowMint`/`allowBurn` counterpart: the modifier next to `onlyMintBurnManager`, the public setter next to `setAllowMint`, the internal setter next to `_setAllowMintBurn`, the hook next to `_authorizeMintBurnManager`. Net: 88 → 100 code lines in the parent, 80 → 68 and 166 → 153 in the two children — 25 lines removed for 12 added.
+>
+> **The risk worth checking was the blast radius, not the move itself.** `RuleWhitelistShared` is a *shared* parent; hoisting a public function into it adds that function to the ABI of everything that inherits it. Had `RuleReceiverWhitelistBase` or `RuleSpenderWhitelistBase` inherited it, they would silently have gained a `setCheckSpender` neither rule should have — `RuleReceiverWhitelist` screens only the receiver by design. They do not: both build on `RuleNFTAdapter` directly, and only `RuleWhitelistBase` and `RuleWhitelistWrapperBase` inherit `RuleWhitelistShared`.
+>
+> Verified rather than reasoned: the **function-level ABI is byte-identical** across `RuleWhitelist` (51), `RuleWhitelistOwnable2Step` (47), `RuleWhitelistWrapper` (53), `RuleWhitelistWrapperOwnable2Step` (48), `RuleReceiverWhitelist` (40), `RuleSpenderWhitelist` (40) and `RuleBlacklist` (42), with both sides confirmed non-empty. Storage layout also identical for both whitelist rules. No rule gained or lost a function.
+>
+> **`RuleIdentityRegistry.setCheckSpender` is deliberately untouched.** It shares only a name: a different flag, guarded by `onlyIdentityRegistryManager` rather than the check-spender manager, emitting `IdentityCheckSpenderUpdated` rather than `CheckSpenderUpdated`, and meaning "also require the spender to be identity-verified" rather than "also require the spender to be whitelisted". That rule does not inherit `RuleWhitelistShared` and should not.
+>
+> **Coverage improved as a side effect.** Both children reached **100%** (from 96.77% and 98.84%): the lines that were uncovered were the duplicated abstract declarations, which now exist once. `RuleWhitelistShared` reads 96.36% only because its two abstract hook declarations have no body — the same tool artifact as `TokenSupplyReader`; branch coverage is 100% (16/16). 700 + 18 tests pass with no test changes, which is the point: the machinery moved, the behaviour did not.
+
+
+
+### D-5. `isVerified` and `_isListedInAnyChild` are the same function — ✅ IMPLEMENTED
+
+```solidity
+// RuleWhitelistWrapperBase.sol:83-88
+function isVerified(address targetAddress) public view virtual override returns (bool) {
+ address[] memory targets = new address[](1);
+ targets[0] = targetAddress;
+ bool[] memory result = _detectTransferRestrictionForTargets(targets);
+ return result[0];
+}
+
+// RuleWhitelistWrapperBase.sol:177-181
+function _isListedInAnyChild(address targetAddress) internal view virtual returns (bool) {
+ address[] memory targets = new address[](1);
+ targets[0] = targetAddress;
+ return _detectTransferRestrictionForTargets(targets)[0];
+}
+```
+
+Same body, 90 lines apart. `isVerified` should be `return _isListedInAnyChild(targetAddress);`.
+
+> **Status: fixed** exactly as written. The NatSpec was also tightened: it now names `_isListedInAnyChild` and says why the delegation matters — it is the same single-address resolution the mint and burn branches of `_detectTransferRestriction` use, so the ERC-3643 eligibility view and the transfer check cannot disagree about an address. That was already true by coincidence of two identical bodies; it is now true by construction.
+>
+> **Trade, measured:** runtime bytecode drops **124 bytes**, worth ~24 800 gas at deployment (confirmed independently — the wrapper-deploying test fell 1 996 153 → 1 971 315, and 124 × 200 = 24 800). Each `isVerified` call costs **+27 gas** for the internal call. Deployment is one-time; `isVerified` is a view, free off-chain, and only on a hot path in the topology where the wrapper fills an ERC-3643 token's identity-registry slot and is consulted per inbound transfer. 27 gas there is not worth keeping a duplicated body for.
+>
+> 700 + 18 tests pass, branch coverage of the file stays at 100% (19/19), and no new test was needed — the four `testIsVerified*` cases already cover listed-in-first-child, listed-in-second-child, listed-nowhere and the empty-wrapper case.
+
+
+
+### D-6. Three public names for one query (observation, not a defect)
+
+`contains` (`RuleAddressSet.sol:120`), `isAddressListed` (`:129`) and `isVerified` (`RuleWhitelistBase.sol:56`) all return `_isAddressListed(targetAddress)`. Each satisfies a different interface (`IIdentityRegistryContains`, `IAddressList`, `IIdentityRegistryVerified`), so the redundancy is imposed from outside and is correct. Noting it only so a future reader doesn't "simplify" one away.
+
+---
+
+## E. `virtual` convention violations
+
+`CLAUDE.md` states: *"All `internal` functions should be marked `virtual`"*, and separately that *"All `_authorize*()` / `_only*()` access-control hooks are `internal view virtual` — both the abstract declaration and every override."*
+
+### E-1. 16 `internal` functions are not `virtual` — ✅ IMPLEMENTED
+
+> **Status: fixed.** All 16 now carry `virtual`; a re-scan of `src/rules`, `src/registry` and `src/modules` reports zero non-`virtual` internals.
+>
+> **Cost: zero gas.** Solidity resolves `internal virtual` calls statically through the C3 linearization — no dynamic dispatch is introduced — so this is a pure capability change. Confirmed rather than assumed: `test_CTL2_EngineKeyedApprovalIsSharedAcrossTokens` (3 278 419), `test_IR1_DelistedHolderCanStillExit` (1 446 463) and `test_MTS1_OverflowReturnsCodeThroughRuleEngine` (3 080 005) report gas **identical to the last digit** before and after.
+>
+> **Regression guard.** A convention with no runtime behaviour is invisible to CI, so `src/mocks/harness/VirtualHookOverrideHarnesses.sol` now contains two subclasses that override the previously-unoverridable hooks: one replaces `_authorizeTransferExecution` with a single-executor policy, the other extends the blacklist's `_detectTransferRestriction` / `…From` via `super`. Removing `virtual` from any of them fails the build with *"Trying to override non-virtual function"* — verified by temporarily deleting one keyword and observing the compiler error. `test/VirtualHooks/VirtualHookOverride.t.sol` additionally asserts the overrides are *reached*: the custom executor is authorized where the bound token is rejected, and `super` still returns the base blacklist code. A compile-only check would not have caught a silently shadowed override.
+
+| File | Line | Function |
+|---|---|---|
+| `RuleConditionalTransferLightBase.sol` | 306 | `_authorizeTransferExecution` ← **an access-control hook** |
+| `RuleAddressSetInternal.sol` | 41, 67 | `_addAddresses`, `_removeAddresses` |
+| `RuleERC2980Internal.sol` | 44, 66, 105, 127 | the four batch helpers |
+| `RuleChainlinkPoRBase.sol` | 366, 400 | `_detectTransferRestriction`, `…From` |
+| `RuleSanctionsListBase.sol` | 141 | `_detectTransferRestriction` |
+| `RuleMaxTotalSupplyBase.sol` | 169, 197 | `_detectTransferRestriction`, `…From` |
+| `RuleIdentityRegistryBase.sol` | 187, 226 | `_detectTransferRestriction`, `…From` |
+| `RuleBlacklistBase.sol` | 114, 140 | `_detectTransferRestriction`, `…From` |
+
+`_authorizeTransferExecution` is the one that matters most: it is exactly the hook class the convention singles out, and dropping `virtual` means no subclass of `RuleConditionalTransferLightBase` can widen or narrow who may execute an approved transfer — the single most likely customisation point on that rule.
+
+The `_detectTransferRestriction*` cases are a clean illustration of drift rather than decision. Across the 20 concrete implementations the split is 11 `virtual` / 9 not, and it follows no rule anyone would state out loud:
+
+| `virtual` | not `virtual` |
+|---|---|
+| `RuleWhitelistBase` :109, :148 | `RuleBlacklistBase` :114, :140 |
+| `RuleWhitelistWrapperBase` :117, :191 | `RuleIdentityRegistryBase` :187, :226 |
+| `RuleReceiverWhitelistBase` :125, :143 | `RuleChainlinkPoRBase` :366, :400 |
+| `RuleSpenderWhitelistBase` :91, :102 | `RuleMaxTotalSupplyBase` :169, :197 |
+| `RuleERC2980Base` :421, :460 | `RuleSanctionsListBase` :141 |
+| `RuleSanctionsListBase` :169 | |
+
+`RuleSanctionsListBase` appears in both columns: `_detectTransferRestriction` at :141 is not `virtual`, its sibling `_detectTransferRestrictionFrom` at :169 is — 28 lines apart, same contract, same purpose. Whitelist-family rules are consistently `virtual`; the blacklist and the three oracle-backed rules are consistently not. That looks like two authors, or two sittings, rather than a decision.
+
+### E-2. `canTransfer` is the odd one out in `RuleTransferValidation` — ✅ IMPLEMENTED
+
+```solidity
+// RuleTransferValidation.sol:67-74
+function canTransfer(address from, address to, uint256 amount)
+ public view override(IERC3643ComplianceRead) returns (bool isValid)
+```
+
+`detectTransferRestriction` (:36), `detectTransferRestrictionFrom` (:49), `canTransferFrom` (:79) and `supportsInterface` (:95) in the same contract are all `public view virtual`. Only `canTransfer` is not, so no rule inheriting this base can override the one view that integrators reach for first.
+
+> **Status: fixed, and it had an exact twin.** The ERC-7943 overload `canTransfer(from, to, tokenId, amount)` in `RuleNFTAdapter.sol:146` had the identical defect — the only non-`virtual` function in *that* core contract, with `detectTransferRestriction`, `detectTransferRestrictionFrom`, `canTransferFrom` and all four `transferred` overloads around it marked `virtual`. Both are now `virtual`. Same story as E-1: no ABI change, and gas identical to the last digit on the three tracked `ThreatModel` tests.
+>
+> Guarded by extending the E-1 harness: `BlacklistQuarantineHarness` now overrides **both** overloads to return `false` unconditionally, deliberately contradicting its own `detectTransferRestriction`, which still returns `TRANSFER_OK`. If the override were not in effect the inherited body would delegate to the restriction hook and answer `true`, so `testSubclassCanOverrideBothCanTransferOverloads` distinguishes a reached override from an ignored one. The test also asserts that `canTransferFrom` — already `virtual`, not overridden — still tracks the restriction hook, confirming only the intended functions moved. Removing either `virtual` fails the build; verified by deleting one and observing `Error (4334)`.
+>
+> **Scope note: this finding understated the problem.** A sweep for non-`virtual` `public`/`external` views across `src/rules`, `src/registry` and `src/modules` (excluding interface declarations, which are implicitly virtual) returns roughly **55** functions, not two — every `messageForTransferRestriction`, every `canReturnTransferRestrictionCode`, both `transferred` views on each validation rule, the whole `RuleERC2980` getter surface, `RuleAddressSet`'s four read functions, the three Ownable2Step `supportsInterface` overrides, and the entire read surface of both conditional-transfer rules. Fixing only the two named here is what E-2 asked for, and they are the two that break the *local* pattern of their own contract; the rest is a codebase-wide sweep in the same class as **E-3** and should be decided together with it rather than piecemeal.
+
+### E-3. 27 public mutating functions are not `virtual`, and the cost is already documented — ✅ IMPLEMENTED
+
+> **Status: fixed.** All 27 now carry `virtual`; a re-scan returns zero. No ABI change and no gas change — the four tracked `ThreatModel` tests report gas identical to the last digit (3 278 419 / 4 060 955 / 1 446 463 / 3 080 005).
+>
+> **Guarded representatively, and that limit is deliberate.** `VirtualHookOverrideHarnesses.sol` gained one override per family — an address-set write (`addAddress`), an ERC-2980 list write (`addWhitelistAddress`), two configuration setters (`setMaxTotalSupply`, `setIdentityRegistry`), an approval write (`approveTransfer`) and a token-facing `transferred` hook — each asserted to run *and* to still reach the base implementation via `super`. Exhaustive coverage was rejected as bulk without signal: `virtual` is applied per function, not per family, so a regression on an uncovered sibling slips through either way. **That residual gap is real** — 21 of the 27 have no compile-time guard. Verified the guard bites by removing `virtual` from `RuleAddressSet.addAddress` and observing `Error (4334)`.
+>
+> **Correction to the third point below.** It said making these `virtual` "would let the registry override them and delete its copies." That overstates it. `CLAUDE.md`'s stated reason for `IdentityRegistryWhitelist` inheriting only the internal layer has two parts, and only one is now moot: the mechanical obstacle (non-`virtual` blocking a `keyHasPurpose` reverse index) is gone twice over — `keyHasPurpose` was itself removed, and the functions are now `virtual` — but the independent reason recorded in `doc/technical/contracts/IdentityRegistryWhitelist.md`, that the registry should expose *exactly one write API* rather than two overlapping ones, still stands on its own. **The registry should not be refactored onto the public layer.** What should change is `CLAUDE.md` / `AGENTS.md`, whose justification now cites a constraint that no longer exists.
+
+Full list: `RuleAddressSet.sol:63,75,87,101`; `RuleERC2980Base.sol:109,119,133,149,164,174,188,204`; `RuleMaxTotalSupplyBase.sol:63,72`; `RuleIdentityRegistryBase.sol:104,134`; `RuleConditionalTransferLightBase.sol:113,133,144,177`; `RuleConditionalTransferLightApprovalBase.sol:54,66`; `RuleConditionalTransferLightMultiTokenBase.sol:99,110,126,147,158`.
+
+Four of them are the `transferred` entrypoints themselves (`RuleConditionalTransferLightBase.sol:133,144` and `MultiTokenBase.sol:147,158`) — the compliance hooks the token calls on every transfer, and the least overridable functions in the library as a result.
+
+Three observations make this more than a style point:
+
+1. **Siblings disagree.** `RuleMaxTotalSupplyBase.setMaxTotalSupply` / `setTokenContract` (:63, :72) are not `virtual`; the equivalent `RuleChainlinkPoRBase.setReservesFeed` / `setTokenMetadata` / `setMaxStalenessSeconds` (:110, :120, :128) all are. Same release, same author, same kind of function.
+2. **Same file, both ways.** `resetApproval` is `public virtual` in both conditional-transfer rules (`ApprovalBase.sol:86`, `MultiTokenBase.sol:187`) while `approveTransfer` and `cancelTransferApproval` beside it are not.
+3. **The bill has already been paid once.** `CLAUDE.md` records that `IdentityRegistryWhitelist` had to inherit only `RuleAddressSetInternal` — rather than the public `RuleAddressSet` layer — *specifically because* `addAddress`/`removeAddress` are not `virtual` and therefore could not be overridden. That workaround is exactly the duplication seen in `IdentityRegistryWhitelistBase.sol:83-87` versus `RuleAddressSet.sol:88-91`. (See the correction above: making them `virtual` removes that obstacle, but a second, independent reason to keep the registry on the internal layer remains, so the duplication stays.)
+
+---
+
+## F. Technically correct, but at odds with the project's purpose
+
+### F-1. The sanctions oracle is asked whether `address(0)` is sanctioned, on every mint and burn — ✅ IMPLEMENTED
+
+```solidity
+// RuleSanctionsListBase.sol:151-157
+if (address(sanctionsList) != address(0)) {
+ if (sanctionsList.isSanctioned(from)) { ... } // from == address(0) on a mint
+ else if (sanctionsList.isSanctioned(to)) { ... } // to == address(0) on a burn
+}
+```
+
+Every other rule in the library treats `address(0)` as what it is — the ERC-20 mint/burn sentinel, not a participant — and handles it explicitly (`RuleWhitelistBase.sol:120-136`, `RuleIdentityRegistryBase.sol:201`, `RuleChainlinkPoRBase.sol:378`, `RuleSpenderWhitelistBase.sol:111`). The sanctions rule instead forwards the sentinel to an external oracle and relies on that oracle answering `false`.
+
+It works with Chainalysis today. What it means is that the rule's mint and burn behaviour is delegated to a third-party contract's handling of a degenerate input: an oracle that returned `true` for `address(0)` — a defensible implementation choice for a contract that has never been asked the question — would block **all minting and all burning** on every token using this rule, with the restriction code pointing at a "sanctioned sender" that is not a real address. For a library whose stated design principle is that a broken oracle must never trap holders (`RuleChainlinkPoR` goes to considerable lengths for exactly this), leaning on an external contract's zero-address semantics is out of character.
+
+It is also a wasted external call on every mint and burn.
+
+The fix matches the rest of the library:
+
+```solidity
+if (from != address(0) && oracle.isSanctioned(from)) { return CODE_ADDRESS_FROM_IS_SANCTIONED; }
+if (to != address(0) && oracle.isSanctioned(to)) { return CODE_ADDRESS_TO_IS_SANCTIONED; }
+```
+
+Note this is a *different* question from "should the minter be screened". `CLAUDE.md` is explicit that the deny-lists deliberately screen the minter, and that arrives as `spender`, which is handled separately at `:177` and should stay.
+
+> **Status: fixed**, exactly as sketched. The minter is still screened as `spender`, pinned by its own test so the guard cannot silently weaken it.
+>
+> **The test gap was the real story.** The whole suite passed *before* any test was written for this — nothing anywhere asserted what a mint or burn does when the oracle has an opinion about `address(0)`. The new `RuleSanctionsListMintBurnSentinel.t.sol` configures an oracle that **does** sanction the zero address and asserts issuance and redemption still work. Reverting the two guards and re-running shows 4 of its 8 tests fail — mint blocked with code `30`, burn with `31`, and `transferred` reverting on the write path — while the 4 asserting unchanged behaviour (real sanctioned participants, the minter-as-spender check) pass either way. That is the shape a regression test should have.
+>
+> **Gas, as a side effect rather than the point** — and the figure first published here was wrong. It compared the *current* mint path (2 478) against the *current* transfer path (3 405) and inferred "about 900 gas", on the reasoning that the difference between a one-participant and a two-participant path is the removed call. That reasoning ignores cold/warm: the removed call read `address(0)`'s slot in the oracle, which nothing else ever touches, so it was **cold on every mint** — whereas a transfer's two calls hit slots real activity keeps warm. Re-measured as a true before/after of the same operation, with the guard toggled in place:
+>
+> | Path | Before | After | Delta |
+> |---|---|---|---|
+> | Mint | 5 308 | 2 478 | **−2 830** |
+> | Burn | 5 308 | 2 478 | **−2 830** |
+> | Plain transfer | 3 309 | 3 405 | **+96** |
+>
+> Three times the saving originally claimed, and it also surfaces a cost the first measurement missed entirely: the two `!= address(0)` guards add 96 gas to every plain transfer, where they are always true. Note the pre-fix mint cost *more* than a pre-fix transfer (5 308 vs 3 309) while screening one fewer real participant — the tell that the sentinel lookup was always cold.
+>
+> **What was deliberately not changed:** the `spender` leg is still passed to the oracle unguarded, so a direct `detectTransferRestrictionFrom(address(0), …)` call still queries the sentinel. Left alone because CMTAT routes plain transfers through the 3-argument path, so a zero spender never reaches this rule from a token — it is only reachable by an off-chain caller constructing the call by hand, where the answer is harmless. Guarding it would be consistent and costs nothing; it is simply outside what this finding claimed, and the finding explicitly said the spender handling should stay.
+
+### F-2. The sanctions `From` path skips the direct check when the oracle is unset — ✅ IMPLEMENTED
+
+```solidity
+// RuleSanctionsListBase.sol:169-183
+if (address(sanctionsList) != address(0)) {
+ if (sanctionsList.isSanctioned(spender)) { return CODE_ADDRESS_SPENDER_IS_SANCTIONED; }
+ return _detectTransferRestriction(from, to, value); // only reachable when the oracle IS set
+}
+return uint8(REJECTED_CODE_BASE.TRANSFER_OK); // never consults the direct check
+```
+
+Correct today, because `_detectTransferRestriction` also returns `TRANSFER_OK` when the oracle is unset. But the delegation sits *inside* the oracle-set branch, so the `From` path silently drops any future check added to `_detectTransferRestriction` that does not depend on the oracle. Every sibling rule delegates unconditionally on the last line (`RuleBlacklistBase.sol:149`, `RuleIdentityRegistryBase.sol:250`, `RuleWhitelistBase.sol:160`). Restructuring to an early return on the unset oracle, then a single unconditional delegation, removes the trap.
+
+> **Status: fixed — but the remedy sketched above does not work, and the finding was understated.**
+>
+> **The sketch was wrong.** "Early return on the unset oracle, then a single unconditional delegation" still returns before delegating when the oracle is unset, so a non-oracle check in `_detectTransferRestriction` would be dropped on exactly the same path. It flattens the code without fixing anything. What actually removes the trap is scoping the oracle guard to the *spender check only* and delegating on the last line regardless:
+>
+> ```solidity
+> ISanctionsList oracle = sanctionsList;
+> if (address(oracle) != address(0) && oracle.isSanctioned(spender)) {
+> return CODE_ADDRESS_SPENDER_IS_SANCTIONED;
+> }
+> return _detectTransferRestriction(from, to, value); // always reached
+> ```
+>
+> which is character-for-character the shape of `RuleBlacklistBase`, `RuleWhitelistBase` and `RuleIdentityRegistryBase`. The unset-oracle case is now handled once, by the direct hook, instead of twice by two functions that could drift.
+>
+> **The finding was understated too.** It called the trap a risk to "any future check". It is reachable *today*: **E-1** made `_detectTransferRestriction` `virtual` on this rule, so a subclass can add an oracle-independent check right now — and before this fix that check applied to `transfer` but silently not to `transferFrom` whenever no oracle was configured. A compliance rule that screens one entrypoint and not the other is a hole, not a latent tidiness issue.
+>
+> **Regression test:** `test/RuleSanctionsList/RuleSanctionsListDelegation.t.sol` (6 tests) with `SanctionsListExtraCheckHarness`, a subclass adding exactly such a check. Two tests fail against the previous structure, and the assertion message states the defect rather than a code number — *"transferFrom must reach the same hook as transfer: 0 != 201"* and *"the receiver leg must be screened identically on both paths: 201 != 0"*. The other four pin unchanged behaviour: the spender check still short-circuits ahead of the delegation, and base screening is untouched.
+>
+> **Cost: +221 gas on the `transferFrom` path when no oracle is configured** (1 547 → 1 768), because that path now reads the slot again inside the delegated hook instead of returning early. The paths that actually screen are unchanged within noise (4 677 → 4 673 clean; 4 503 → 4 514 spender-sanctioned), and a plain `transfer` is identical. Accepted: a rule with no oracle is a no-op that should not be installed at all, and 221 gas on it buys the guarantee that both entrypoints screen alike.
+
+
+
+### F-3. Dead condition in `RuleIdentityRegistryBase.sol:244-249` — ✅ IMPLEMENTED
+
+```solidity
+if (to == address(0)) { return TRANSFER_OK; } // line 236
+...
+if (checkSpender && spender != address(0) && from != address(0) && to != address(0) // <-- always true here
+ && !identityRegistry.isVerified(spender)) {
+```
+
+`to != address(0)` cannot be false at line 245 — line 236 already returned. Harmless, but it costs a comparison on the hot path and, more importantly, it reads as though the burn case were being handled here when it was handled nine lines earlier. Delete it.
+
+> **Status: fixed.** The term is gone and, more usefully, the comment above it was wrong in the same way: it read *"Mint (from == 0) and burn (to == 0) are exempt"*, crediting this condition with a burn exemption the early return actually provides. It now says where burn is really handled and warns against re-adding the test.
+>
+> **Measured, same harness, term toggled in place:**
+>
+> | Path | With dead term | Without | Delta |
+> |---|---|---|---|
+> | `transferFrom`, both flags on | 5 242 | 5 193 | **−49** |
+> | `transferFrom`, receiver-only (the ERC-3643 default) | 3 182 | 3 162 | **−20** |
+> | `transferFrom`, burn | 1 593 | 1 593 | **0** |
+>
+> Two things worth noting in those numbers. The default path saves 20 gas even though `checkSpender == false` short-circuits before the removed term is ever evaluated — dropping a term shortens the branch layout, not just the evaluation. And the burn path is **unchanged to the gas**, which is direct evidence for the corrected comment: burn never reaches this condition, it returns at the guard six lines above.
+>
+> A first attempt compared against the B-3 benchmark numbers and appeared to show a 78-gas saving. That was a cross-harness comparison and therefore wrong; re-measuring with the term toggled inside one harness gives 49. The smaller number is the real one.
+>
+> **No new test.** Behaviour is identical, and the case the term appeared to guard is already pinned: `testBurnBypassesAllChecks` sets `checkSender` and `checkSpender` to `true`, burns with an unverified spender, and asserts `TRANSFER_OK`. Branch coverage of the file stays at 100% (19/19).
+
+
+
+### F-4. `_transferHash` produces neither `abi.encode` nor `abi.encodePacked`, but the comment claims "packed" — ✅ OPTION 1 IMPLEMENTED
+
+```solidity
+// RuleConditionalTransferLightApprovalBase.sol:150-159
+// Linter suggestion (`asm-keccak256`): hash packed values in assembly to avoid abi.encodePacked overhead.
+assembly ("memory-safe") {
+ let ptr := mload(0x40)
+ mstore(ptr, shl(96, from)) // address in the HIGH 20 bytes, 12 zero bytes after
+ mstore(add(ptr, 0x20), shl(96, to))
+ mstore(add(ptr, 0x40), value)
+ hash := keccak256(ptr, 0x60) // 96 bytes
+}
+```
+
+The assembly is sound — the encoding is injective, so there is no collision risk, and `CLAUDE_AUDIT.md` F-12 already verified that. The problem is the comment. `abi.encodePacked(from, to, value)` is **72 bytes** with no padding; `abi.encode(from, to, value)` is 96 bytes with the addresses *right*-aligned. This hashes 96 bytes with the addresses *left*-aligned — a third, project-specific encoding that matches neither.
+
+Anyone who reads "hash packed values" and reimplements the key off-chain as `keccak256(abi.encodePacked(from, to, value))` — to pre-compute an approval key for a subgraph, a monitoring bot or a test fixture — gets a different hash and a silent mismatch. The `approvedCount(from, to, value)` getter is the supported way to query, so nothing on-chain breaks, but the comment actively points readers at the wrong equivalence. Reword it to state the layout explicitly.
+
+---
+
+#### The exact preimage
+
+The single-token hash is **96 bytes**, three 32-byte words, with each address **left**-aligned and right-padded with 12 zero bytes:
+
+```
+word 0 : from (20 bytes) ‖ 0x00 × 12
+word 1 : to (20 bytes) ‖ 0x00 × 12
+word 2 : value (32 bytes, big-endian)
+```
+
+The multi-token variant (`RuleConditionalTransferLightMultiTokenBase`) is the same shape with `token` prepended — **128 bytes**: `token ‖ pad`, `from ‖ pad`, `to ‖ pad`, `value`.
+
+This is why neither standard encoding matches: `abi.encodePacked(from, to, value)` is 72 bytes with **no** padding, and `abi.encode(from, to, value)` is 96 bytes with the addresses **right**-aligned.
+
+#### Yes, it can be recomputed off-chain — two verified formulations
+
+Both of these reproduce the key exactly. Verified empirically, not derived on paper: each candidate hash was fed to the contract's own public `approvalCounts(bytes32)` getter after recording one approval, and only these two returned `1`.
+
+```solidity
+// (1) explicit padding
+keccak256(abi.encodePacked(from, bytes12(0), to, bytes12(0), value))
+
+// (2) left-aligned words — identical bytes to (1)
+keccak256(abi.encode(bytes32(bytes20(from)), bytes32(bytes20(to)), value))
+```
+
+| Candidate | `approvalCounts(candidate)` |
+|---|---|
+| `abi.encodePacked(from, to, value)` — what the comment implies | **0** ❌ |
+| `abi.encode(from, to, value)` | **0** ❌ |
+| `abi.encode(bytes32(bytes20(from)), bytes32(bytes20(to)), value)` | **1** ✅ |
+| `abi.encodePacked(from, bytes12(0), to, bytes12(0), value)` | **1** ✅ |
+
+In JavaScript the byte layout above is the authoritative spec; with ethers it is
+`keccak256(solidityPacked(["address","bytes12","address","bytes12","uint256"], [from, ZERO12, to, ZERO12, value]))`.
+
+#### Is there a use case for recomputing it?
+
+Mostly **no** — and that is the main reason not to churn the implementation.
+
+| Need | Requires the hash? |
+|---|---|
+| Read the outstanding approval count | **No** — `approvedCount(from, to, value)` computes it for you (`approvedCount(token, from, to, value)` on the multi-token rule) |
+| Index approvals off-chain | **No** — `TransferApproved` / `TransferExecuted` / `TransferApprovalCancelled` carry `from`, `to`, `value` (and `token`), with `from` and `to` indexed |
+| Call the public `approvalCounts(bytes32)` getter | Yes — but it is strictly redundant with `approvedCount` |
+| Derive the storage slot for `eth_getStorageAt`, a state proof, or a subgraph reading storage rather than events | **Yes** — this is the one genuine case |
+
+So the practical exposure is narrow. The realistic failure is not "someone cannot compute the hash", it is **"someone computes it wrongly because the comment told them it was packed"** — and a wrong key silently reads `0`, which looks exactly like "no approval exists" rather than like an error. A monitoring bot built that way would report every approval as missing.
+
+#### Options
+
+| # | Option | Verdict |
+|---|---|---|
+| 1 | Fix the comment; document the preimage in NatSpec | ✅ **recommended** |
+| 2 | Replace the assembly with `abi.encodePacked` and silence the linter | ➖ possible, costs ~109 gas per transfer |
+| 3 | Replace with `abi.encode` | ❌ same cost, no clarity gain over (2) |
+
+**Option 2 in detail**, since it is the one the question asks about. It is entirely feasible: the project already suppresses this exact lint rule at `src/mocks/ERC3643TokenMock.sol:263`, so the pattern and syntax are established:
+
+```solidity
+// forge-lint: disable-next-line(asm-keccak256)
+return keccak256(abi.encodePacked(from, to, value));
+```
+
+Two costs, one of which matters:
+
+- **Gas: ~109 per call**, measured with each variant in its own single-function contract so dispatch is identical — assembly 1 032, `abi.encodePacked` 1 141, `abi.encode` 1 149. `_transferHash` is called from `_transferred`, i.e. on the **transfer write path**, so this is paid by the transferring holder on every conditional transfer, not by an operator.
+- **It changes every storage key.** The approval mapping is keyed by this hash, so switching the encoding orphans every outstanding approval in any already-deployed instance. For a fresh deployment that is harmless; as an upgrade to a live rule it would silently strand approvals that `resetApproval` could then no longer reach, because the caller would compute the *new* key. That makes it a change to do only alongside a version bump and a migration note.
+
+**Recommendation: option 1.** The encoding is sound — `CLAUDE_AUDIT.md` F-12 already verified its injectivity, so there is no collision risk — it is cheaper than the alternatives on a holder-paid path, and it is now fully documented above. What was broken was the comment, not the code. Reword it to state the 96-byte layout, add the two equivalent formulations to the NatSpec so the one genuine use case (storage-slot derivation) is served, and keep the assembly.
+
+> **Status: done.**
+>
+> - The misleading inline comment is gone. It now says the assembly is hand-rolled on the linter's `asm-keccak256` advice because this sits on the transfer write path and is ~109 gas cheaper, and points at the documented layout — no longer implying `abi.encodePacked` equivalence.
+> - `_transferHash` NatSpec on **both** rules now states the exact word-by-word layout, warns that it is neither standard encoding, explains that the mistake is *silent* (a wrong key reads `0`, indistinguishable from "no approval"), gives both reproducing formulations, and points readers at `approvedCount` as the supported path so only the storage-slot case reaches for the hash.
+> - The assembly is unchanged.
+>
+> **Pinned by `test/RuleConditionalTransferLight/TransferHashPreimage.t.sol` (4 tests).** They assert the documented formulations against the contract's own public `approvalCounts(bytes32)` getter — the real storage key — rather than against a reimplementation of the assembly, so documentation and code cannot drift apart. Two tests assert the *negative* case as well: `abi.encodePacked(from,to,value)` and `abi.encode(from,to,value)` must both return `0`, which is the specific error the NatSpec warns about.
+>
+> Verified the guard bites by changing `shl(96, from)` to `from` in the assembly: `documented encodePacked form must hit the key: 0 != 1`. The multi-token 128-byte layout was documented from inspection and then confirmed by its own test — it passed first time, but it was worth checking rather than asserting.
+
+### F-5. Batch add **reverts** on `address(0)` — contradicting `CLAUDE.md`, `AGENTS.md` and the functions' own NatSpec — ✅ IMPLEMENTED
+
+The code deliberately rejects the zero address inside the batch loop, with a well-argued comment:
+
+```solidity
+// RuleAddressSetInternal.sol:43-49
+// The zero address is the mint/burn sentinel, never a participant. It is REJECTED
+// rather than skipped: the batch convention skips *duplicates* ... but silently dropping
+// address(0) would make `AddAddresses` report a member that is not in the set ...
+require(addressesToAdd[i] != address(0), RuleAddressSet_ZeroAddressNotAllowed());
+```
+
+Three documents say otherwise:
+
+- `CLAUDE.md` / `AGENTS.md`, invariant I-12: *"the zero address can never enter any list — single adds revert, **batch adds skip it**."* The code reverts.
+- `CLAUDE.md` / `AGENTS.md`, Conventions: *"Batch add/remove operations are non-reverting (skip duplicates); single-item operations revert on invalid input."* The batch is not non-reverting.
+- The NatSpec on `addAddresses` (`RuleAddressSet.sol:57-61`) and on all four ERC-2980 batch adders documents only *"Does not revert if an address is already listed"* — it never mentions the zero-address revert that the function will actually perform.
+
+The code's reasoning is better than the documentation's, so the fix is to update the docs, not the code — but the mismatch matters operationally: an operator batching a list that happens to contain a zero entry loses the entire batch, and neither the function's own NatSpec nor the agent-facing invariant warns them. Same issue in `RuleERC2980Internal.sol:51` and `:112`.
+
+> **Status: fixed as a documentation change. No Solidity behaviour was altered** — the code is right and stays exactly as it was.
+>
+> Corrected in five places:
+> - `CLAUDE.md` / `AGENTS.md` **I-12** — "single adds revert, batch adds skip it" was simply false; it now says both revert on `address(0)` and gives the reason.
+> - `CLAUDE.md` / `AGENTS.md` **Conventions** — "Batch add/remove operations are non-reverting" now scopes that to duplicates and missing entries, with the `address(0)` exception called out.
+> - `README.md` ERC-2980 section — same correction, cross-linked to the new section below.
+> - `README.md` — new **Zero address in batch operations** section with a single/batch behaviour table, the rationale, and the operational consequence (a truncated CSV column costs you the whole batch, not 999 of 1000 rows).
+> - NatSpec on all six batch-add functions (`RuleAddressSet`, `RuleAddressSetInternal`, `RuleERC2980Base` ×2, `RuleERC2980Internal` ×2), which previously mentioned only the duplicate-skipping half.
+>
+> **The README contradicted itself**, which the finding missed: line 642 stated "Batch operations remain non-reverting" while the static-analysis triage table at line 1811 already recorded *"Batch adds revert on `address(0)` on purpose"*. Both are now consistent with the code.
+>
+> **A test gap turned up while documenting.** Only `RuleWhitelistAdd.t.sol:90` covered the batch zero-address revert, and it exercises `RuleAddressSetInternal`. `RuleERC2980` keeps its **own copy** of that guard (`RuleERC2980Internal` — the duplication that is **D-1**), so its two batch adders had no coverage at all: a future "fix" that made them skip the sentinel would have gone unnoticed. Added `testBatchAddRejectsZeroAddressAndAppliesNothing`, which also pins that the batch is atomic — the valid entries either side of the sentinel are not applied — and `testBatchAddStillSkipsDuplicates` for the contrast that makes the convention coherent.
+
+
+
+### F-6. `RuleMintAllowance.canTransfer` always returns `true` — ✅ OPTION 1 IMPLEMENTED
+
+```solidity
+// RuleMintAllowanceBase.sol:227-235
+function canTransfer(address, address, uint256) public view virtual override returns (bool) {
+ return true;
+}
+```
+
+Documented in `CLAUDE.md` (*"`canTransfer` is not authoritative for this rule"*) and in the contract's own NatSpec, and the reason is real: the 3-argument signature carries no minter identity. Recording it here because it is the clearest instance of the pattern the review was asked to look for — a rule in a *compliance* library whose headline "may this transfer proceed?" view answers `true` for a mint it will then revert.
+
+The consequence worth stating is what happens one level up. `RuleEngineBase._detectTransferRestriction` (`lib/RuleEngine/src/RuleEngineBase.sol:148-157`) walks its rules calling each one's 3-argument `detectTransferRestriction` and returns the first non-zero code; `RuleEngineBase.canTransfer` is that result `== 0`. `RuleMintAllowance` contributes a hard `0`, so **an engine-level `canTransfer` pre-flight silently drops the quota check** for every token the engine serves — it does not merely under-report on the rule itself.
+
+The 4-argument path is fine: `RuleEngineBase._detectTransferRestrictionFrom` (`:159-173`) calls each rule's `detectTransferRestrictionFrom`, which for this rule does consult `mintAllowance`. So the mitigation already exists and is the one `CLAUDE.md` prescribes — `canTransferFrom(minter, address(0), to, value)`. It is worth saying explicitly in the README that this holds *through the engine*, not just when querying the rule directly, because the engine is the address integrators actually call.
+
+The same shape appears in `RuleConditionalTransferLightMultiTokenBase.detectTransferRestriction` (`:222-229`), which is caller-dependent and returns "not approved" to any off-chain `eth_call` — that one is documented at length and is `CLAUDE_AUDIT.md` F-4. Neither is a new defect; both are worth a single "views that are not authoritative" table in the README so integrators meet them once rather than per rule.
+
+---
+
+#### What is already covered, and what is not
+
+This is **not** a new discovery at the rule level: `CLAUDE_AUDIT.md` **F-7** records it (threat `MA-1`, PoC `test_MA1_HardcodedEligibilityViewsDisagreeWithEnforcement_CurrentBehaviour`), and resolution **I-8** already added a bold callout plus an *"Eligibility views: which one is authoritative"* table to `doc/technical/contracts/RuleMintAllowance.md`, with matching warnings at `README.md:338` and `:754`. Do not redo that work.
+
+What none of those say is **how far the blind spot travels**. Every existing sentence is phrased about querying *the rule*. The call chain is three levels deep, and each level inherits the hard `true`:
+
+| Level | Call | Quota checked? |
+|---|---|---|
+| Rule | `rule.canTransfer(0, to, value)` | ❌ hardcoded `true` |
+| Engine | `ruleEngine.canTransfer(0, to, value)` → `_detectTransferRestriction` → each rule's 3-arg view | ❌ this rule contributes `0` |
+| Token | `cmtat.detectTransferRestriction(0, to, value)` → `ruleEngine.detectTransferRestriction(...)` (`ValidationModuleERC1404.sol:98-108`) | ❌ |
+| Token, 4-arg | `cmtat.detectTransferRestrictionFrom(minter, 0, to, value)` → engine `:114-128` → rule | ✅ **real answer** |
+
+The **token** is the address a wallet, explorer or issuance UI actually calls — not the rule, and not usually the engine. So the audience most likely to be misled is the one furthest from the documentation that warns them. That gap is what F-6 adds over `CLAUDE_AUDIT.md` F-7.
+
+#### Options considered
+
+| # | Option | Verdict |
+|---|---|---|
+| 1 | Keep the behaviour; document the propagation to engine and token; pin it with a test | ✅ **recommended** |
+| 2 | Return a non-zero code from the 3-arg path whenever `from == address(0)` | ❌ rejected |
+| 3 | Derive the minter from `_msgSender()` in the 3-arg path | ❌ rejected |
+| 4 | Add explicit `…ForMinter` views mirroring the multi-token rule's fix | ➖ optional, low value |
+| 5 | Re-key the quota on the recipient so 3 arguments suffice | ❌ different rule |
+| 6 | Fix the aggregation in `RuleEngine` | ⬆️ upstream, out of scope |
+
+**Option 2 — return a restriction code on the 3-arg mint path.** Tempting, because it converts a false "allowed" into something safe-looking. It is worse than the status quo. ERC-1404 has no "cannot answer" code: every non-zero value reads as *blocked*, so the engine's aggregate — and therefore the token's view — would report **every mint as forbidden**, including the overwhelming majority that will succeed. A false "no" on every issuance breaks mint UIs and pre-flight gating far more often than a false "yes" misleads. Trading a rare wrong-positive for a constant wrong-negative is not a fix.
+
+**Option 3 — use `_msgSender()` as the minter.** This is precisely the defect `CLAUDE_AUDIT.md` F-8 already records against `RuleConditionalTransferLightMultiToken.detectTransferRestriction`, where the token is derived from `msg.sender` and every off-chain `eth_call` therefore gets a meaningless answer. Importing a pattern this codebase has already identified as a problem, to fix a different instance of the same problem, would be a step backwards.
+
+**Option 4 — `detectTransferRestrictionForMinter(minter, to, value)` / `canTransferForMinter(...)`.** This is the shape the multi-token rule adopted (`detectTransferRestrictionForToken` / `canTransferForToken`) for exactly this class of problem, so there is precedent. But the capability **already exists**: `canTransferFrom(minter, address(0), to, value)` is the same function with a different name. The gain is discoverability — a named function states "pass the minter", whereas `canTransferFrom(minter, address(0), …)` requires knowing that `address(0)` means "mint". The cost is two more functions to keep in sync on a rule whose surface is already documented. Worth doing only if integrator confusion shows up in practice; not worth doing pre-emptively.
+
+**Option 5 — key the quota on the recipient.** Then three arguments would suffice and every view would be authoritative. But it is no longer a per-minter quota; it is a per-recipient issuance cap, a different control with different governance. If what is actually wanted is a supply constraint that pre-flights correctly from 3 arguments, the library already has two: `RuleMaxTotalSupply` and `RuleChainlinkPoR`, both of which gate on `from == address(0)` and need no identity.
+
+**Option 6 — upstream.** `RuleEngineBase` could consult `detectTransferRestrictionFrom` when a rule signals that its 3-arg view is not authoritative. That is a change to `lib/RuleEngine`, a separate repository, and it would need an interface for rules to advertise the property. Worth raising there; nothing to do in this repo.
+
+#### Recommended work
+
+1. **Extend the existing I-8 documentation one level up.** In `doc/technical/contracts/RuleMintAllowance.md`, add the engine and token rows to the *"Eligibility views: which one is authoritative"* table — the current table stops at the rule. State plainly that `cmtat.detectTransferRestriction` and `ruleEngine.canTransfer` inherit the hard `true`, and that `cmtat.detectTransferRestrictionFrom(minter, address(0), to, value)` is the authoritative pre-flight for an integrator holding only the token address.
+2. **Add the README "views that are not authoritative" table** proposed above, covering `RuleMintAllowance` and `RuleConditionalTransferLightMultiToken` together, so an integrator meets the whole class once instead of discovering it per rule.
+3. **Pin the propagation with a test.** `test_MA1_…` asserts the rule in isolation. Add an engine-level case — rule in a `RuleEngine`, zero quota, assert `ruleEngine.canTransfer(address(0), to, value) == true` while `ruleEngine.canTransferFrom(minter, address(0), to, value) == false`, and that the mint then reverts. Name it `_CurrentBehaviour` per the project convention, so that if anyone later adopts option 2 or 6 the test fails and forces the documentation to be updated with it.
+4. **Leave the Solidity alone.** The hardcoded `true` is the correct answer to a question that cannot be answered from three arguments.
+
+#### Implementation of option 1
+
+All four steps done; `git diff src/` is empty, as the option requires.
+
+1. **`doc/technical/contracts/RuleMintAllowance.md`** — new subsection *"The blind spot propagates to the RuleEngine and to the token"* under the existing authoritative-views table, with a second table mapping each entrypoint (`cmtat.*`, `ruleEngine.*`) to whether the quota is actually checked, and a callout for the case that matters: an integrator holding only the token address must pre-flight with `cmtat.detectTransferRestrictionFrom(minter, address(0), to, value)`.
+2. **`README.md`** — new *"Views that are not authoritative"* section covering `RuleMintAllowance` and `RuleConditionalTransferLightMultiToken` together, so the class is met once rather than per rule, with the propagation mechanism and the reason returning a restriction code instead would be worse.
+3. **`test_MA1_EngineAndTokenInheritTheHardcodedAllowedView_CurrentBehaviour`** in `test/ThreatModel/ThreatModelTests.t.sol` — wires the rule into a real `RuleEngine` inside a real CMTAT and asserts the blind spot at **both** levels, the real answer from the 4-argument chain at both levels, and that enforcement reverts.
+4. **No Solidity touched.**
+
+**The test confirmed every documented claim rather than assuming them.** Before writing the tables I had reasoned that the engine aggregate and CMTAT's `ValidationModuleERC1404` forward the 3-argument call; the test now demonstrates it end to end — `ruleEngine.canTransfer` and `cmtat.canTransfer` both return `true` for a minter with zero quota, while `cmtat.detectTransferRestrictionFrom` returns `70` and the mint reverts.
+
+**And it empirically settles the argument against option 2.** Temporarily changing `detectTransferRestriction` to return `CODE_MINTER_ALLOWANCE_EXCEEDED` on the mint path — exactly option 2 — makes the test fail with `70 != 0` at the engine level, confirming that the code propagates all the way out to the token and would make `cmtat.detectTransferRestriction` report **every** mint as forbidden, including mints that will succeed. That is no longer an argument from reasoning; it is a measured outcome. The rule was restored immediately afterwards.
+
+**The test is a `_CurrentBehaviour` guard, not an endorsement.** It asserts what the audit considers wrong. If the rule, `RuleEngineBase`, or CMTAT is ever changed to close the gap, it fails — forcing whoever does that to update `CLAUDE_ANALYSIS.md` F-6, `CLAUDE_AUDIT.md` F-7 and both documentation tables in the same change.
+
+
+
+### F-7. Nits
+
+Three unrelated items, separated so each can be accepted or declined on its own. None affects behaviour.
+
+#### F-7a. Empty `INTERNAL FUNCTIONS` banner — `IdentityRegistryWhitelistBase.sol:156-158` — ✅ IMPLEMENTED
+
+```solidity
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+}
+```
+
+A section header with nothing under it, immediately before the closing brace. The contract's only internal member, `_authorizeIdentityRegistrar`, sits above it under the `ACCESS CONTROL` banner, so the section is not misplaced — it is empty.
+
+**Verdict: delete it.** Zero risk. An empty banner is a small invitation to put the *next* internal function in the wrong place, below the access-control section instead of beside it.
+
+> **Status: done.** Three lines removed, nothing else touched.
+
+#### F-7b. `version()` could be `pure` — `VersionModule.sol:23` — ✅ IMPLEMENTED
+
+```solidity
+function version() public view virtual override returns (string memory version_) {
+ return VERSION; // private constant
+}
+```
+
+`VERSION` is a compile-time constant, so the function reads no state. Solidity permits an override to tighten mutability (`view` → `pure`), and `IERC3643Version.version()` is declared `view`, so `pure` would compile.
+
+**Verdict: optional, and marginal.** There is no gas difference — `view` and `pure` are both `STATICCALL`-able and the distinction is not enforced on-chain. The gain is that the signature would state "this can never depend on state", which is the actual invariant. The cost is a deviation from the interface's own declaration that a reader may find surprising.
+
+> **Status: done — the project chose to tighten it, superseding the "recommend leaving" above.**
+>
+> One of my two arguments for leaving it does not survive contact with the codebase: **the precedent already exists.** `AggregatorV3Mock.version()` is declared `external pure override` against an `AggregatorV3Interface.version()` that is `external view`. So "a reader may find the deviation surprising" was already false — the pattern is in the repo. That leaves only the no-gas-difference point, which argues neither way, and the accuracy argument, which favours `pure`.
+>
+> **This is a real ABI change, though a harmless one.** Verified across `RuleWhitelist`, `RuleChainlinkPoR`, `RuleMintAllowance` and `IdentityRegistryWhitelist`: the *only* difference in any of their ABIs is `version`'s `stateMutability` field, `view` → `pure`. The selector is unchanged (same name, no inputs), every other function is byte-identical, and both mutabilities are read-only, so `eth_call` consumers and every mainstream client library treat them the same. An integrator that diffs ABI JSON between releases will see it; one that calls the function will not.
+>
+> `test/Version.t.sol`, which asserts the version string for all 14 deployable contracts, passes unchanged.
+
+#### F-7c. Redundant `allowance` pre-check — `RuleConditionalTransferLightMultiTokenBase.sol:141-146`
+
+```solidity
+uint256 allowed = IERC20(token).allowance(from, address(this));
+require(allowed >= value, RuleConditionalTransferLightMultiToken_InsufficientAllowance(token, from, allowed, value));
+IERC20(token).safeTransferFrom(from, to, value);
+```
+
+`safeTransferFrom` would revert on an insufficient allowance anyway, so the explicit read is not needed for correctness. It costs one extra external call (~2 600 gas cold).
+
+**Verdict: keep it.** What it buys is a *named* error carrying `token`, `from`, the actual allowance and the required value. Without it the operator gets whatever the token happens to revert with — for many ERC-20s a bare `revert` with no data, or an opaque `ERC20InsufficientAllowance` that does not name the rule as the spender. For an operator-driven function on a compliance rule, a diagnostic that says *which* token, *whose* allowance and *how short* is worth 2 600 gas. Recorded here so the trade is visible and deliberate, not so it gets removed.
+
+---
+
+## Suggested order of work
+
+1. **C-1, C-2, C-3** — constructor events. Small, mechanical, and they close a real observability gap on rules that are typically configured once at deployment and never touched again.
+2. ~~**B-4** — the double set lookup. Largest gas win, ten sites, no behaviour change.~~ **Done**, but the ranking was wrong: eight sites, ~288 gas each, not the largest win. See the correction in B-4.
+3. **E-1 (`_authorizeTransferExecution`) and E-2** — restore `virtual` where its absence blocks the most likely extension points.
+4. **F-5** — fix `CLAUDE.md` / `AGENTS.md` I-12 and the batch NatSpec to match what the code actually does. Documentation-only; must update both agent files together per the project convention.
+5. **F-1, F-2, F-3** — the sanctions zero-address screening, the `From`-path structure, and the `_transferHash` comment.
+6. **D-4, D-5, A-2** — localised de-duplication and the wrapper loop; contained, low risk.
+7. **D-1, D-2** — the structural refactors. Real value, but they touch storage-layout-adjacent code in `RuleERC2980` and two shipped rules, so they deserve their own change with the full suite (both Foundry profiles) behind them.
+
+Nothing in this list requires a behavioural change to any rule's restriction logic, so the existing test suite should stay green throughout — with one exception: fixing **F-1** changes the number of external calls a sanctions check makes on mint/burn, which any gas-snapshot or call-count assertion will notice.
diff --git a/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md
new file mode 100644
index 00000000..b9f84599
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_MAXBALANCE.md
@@ -0,0 +1,293 @@
+# Claude Code Analysis — RuleMaxBalance and the ChainlinkPoR split
+
+Report version: `v0.5.0`
+Tool: **Claude Code** (Anthropic) — interactive review and implementation session, model Opus 5
+Compiler: solc `0.8.36`, optimizer on (200 runs), EVM `prague`
+Scope: the code added in this session — `RuleMaxBalanceBase`, `RuleMaxBalance`,
+`RuleMaxBalanceOwnable2Step`, `RuleMaxBalanceInvariantStorage`, `IBalanceOf`, `BalanceOfMock`, and
+`ChainlinkPoRFeedManager` extracted from `RuleChainlinkPoRBase`. Sibling rules are read only as the
+comparison baseline.
+
+**This is a code-quality review, not a security audit. Nothing here is a vulnerability.** No finding lets an
+unauthorized party move value, bypass a restriction or brick a contract. The one item with real
+correctness weight (H-1) is an *assumption that currently holds* and was undocumented and unpinned; it is now
+both. The known limitation of this rule — that a per-address cap is bypassable by splitting a position across
+wallets — is a documented design property, not a defect, and is covered in
+[`RuleMaxBalance.md`](../../../../technical/contracts/RuleMaxBalance.md).
+
+## Disposition summary
+
+| ID | Finding | Outcome | Commit |
+| --- | --- | --- | --- |
+| A-1 | No loops in the rule; batch delegates to the shared library, already `calldata` | ✅ Nothing to do — verified | — |
+| B-1 | Check order: exemption lookup before the balance read | ⬜ **Left as is** — measured, the alternative is worse for the likely traffic mix | — |
+| C-1 | Exemption events emitted from public functions while scalar setters use `_setX` helpers | ✅ Fixed — `_addExemptAddress` / `_removeExemptAddress` own guards + write + event | `PENDING` |
+| D-1 | `_balanceOf` mirrors `TokenSupplyReader._currentSupply` in shape | ⬜ **Left as is** — different functions, one rule; extraction would be premature | — |
+| D-2 | detect-then-`require` `_transferred` pair now in a 10th rule | ⬜ Left as is — consistent with the earlier `D-3` decision | — |
+| E-1 | Three functions not `virtual` (`canReturnTransferRestrictionCode`, both `transferred`) | ✅ Nothing to do — matches every sibling exactly | — |
+| F-1 | `IAddressList` deliberately not advertised | ✅ **Keep** — advertising it would let the rule be misread as a whitelist | — |
+| F-2 | Codes `82` / `83` unique across the library | ✅ Verified | — |
+| F-3 | `address(0)` could enter the exemption set | ✅ Fixed during implementation — guard was missing on the single-add path | (in the feature commit) |
+| G-1 | Documentation claims checked against the code | ✅ Verified — no mismatch found | — |
+| H-1 | The cap silently depends on the token notifying *before* it moves value | ✅ Fixed — documented and pinned by a mutation-verified test | `PENDING` |
+| H-2 | `remainingCapacity` returns code `OK` with headroom `0` for a holder at the cap | ⬜ Left as is — documented; `OK` means the query succeeded | — |
+
+12 findings: 6 verified-as-correct or nothing-to-do, 2 implemented, 4 deliberately left.
+
+## Outstanding
+
+| ID | Item | Why it is still open |
+| --- | --- | --- |
+| B-1 | Check order | Deliberate. Revisit only with real traffic data showing exempt receivers exceed ~19% of inbound transfers |
+| E-1 | Non-`virtual` `transferred` overloads | Library-wide, out of scope here. Same set as the earlier `E-2` scope note (~55 public views) |
+
+---
+
+## A. Loops and iteration
+
+### A-1. No iteration of its own — verified, nothing to do
+
+`RuleMaxBalanceBase` contains no loop. The batch entrypoints take `address[] calldata` and delegate to
+`AddressSetBatchLib`, which already owns the only loops and was reviewed under `D-1` in the main analysis.
+
+```solidity
+function addExemptAddresses(address[] calldata targetAddresses) public virtual onlyMaxBalanceManager {
+```
+
+`calldata` rather than `memory`, so the `A-3` finding still open against `areAddressesListed` does not
+recur here. The compiler is `0.8.36`, so `unchecked { ++i }` would buy nothing anywhere and is correctly
+absent.
+
+**Verdict: nothing to do.**
+
+## B. Storage reads
+
+### B-1. The exemption lookup runs before the balance read — measured, and kept
+
+`_detectTransferRestriction` resolves in this order: sentinel, exemption set, balance, cap.
+
+```solidity
+if (to == address(0)) { return TRANSFER_OK; }
+if (_isAddressListed(to)) { return TRANSFER_OK; } // <- cold SLOAD on every non-exempt transfer
+(bool available, uint256 balance) = _balanceOf(to); // <- external call
+uint256 cap = maxBalance;
+```
+
+Every non-exempt transfer — the common case — pays a cold `SLOAD` for a set membership test that almost
+always answers "no". Reordering so the balance is read first, and the exemption set consulted only on the
+paths that would otherwise reject, removes that read from the common path.
+
+Measured by toggling the reorder in place on the real contract and re-running one harness, each path in its
+own transaction:
+
+| Path | Current order | Reordered | Δ |
+| --- | --- | --- | --- |
+| Non-exempt, under the cap | 14 995 | 12 684 | **−2 311** |
+| Exempt receiver | 2 995 | 12 684 | **+9 689** |
+
+The reorder is not free: an exempt receiver currently short-circuits before the external call, and afterwards
+would always pay it. Break-even is at **19.3%** of inbound transfers going to exempt addresses
+(`2311 / (2311 + 9689)`).
+
+**Verdict: leave as is.** Three reasons, in order of weight:
+
+1. **Exempt addresses are the high-traffic ones.** The exemption list exists for custodians, omnibus accounts,
+ treasury and redemption contracts — precisely the addresses that receive most often. A mix above 19% is
+ not a corner case for this rule, it is the expected shape.
+2. **The current order keeps a useful property**: exempt receivers and burns are decided without reading a
+ balance, so they keep working while the token's `balanceOf` is broken. That is pinned by
+ `testBrokenTokenStillAllowsBurnAndExempt`. The reorder preserves it only by adding the exemption test to
+ both failure branches, which is more code for a worse average.
+3. The saving is 15% of a check whose cost is dominated by an external call neither order removes.
+
+Revisit only with deployment data showing exempt receivers below ~19% of inbound transfers.
+
+### B-2. Single reads elsewhere — verified
+
+`maxBalance` is read once into `cap` before both comparisons; `balanceToken` is read once per `_balanceOf`.
+No slot is read twice across an external call. Nothing to hoist.
+
+## C. Events
+
+### C-1. Exemption writes emitted inline while scalar writes use `_setX` helpers — fixed
+
+Every event has exactly one emit site (verified by `grep -rc 'emit ' src/`), so no invariant was at
+risk. The finding is an *internal inconsistency*: `MaxBalanceUpdated` and `MaxBalanceTokenUpdated` are owned
+by `_setMaxBalance` / `_setBalanceToken`, which hold validation, write and event together — while the
+exemption events were emitted from the public functions, with the guards inline beside them.
+
+That is the house style in this codebase (the same pattern the main analysis's `C-1`–`C-3` established), and
+the exemption path was the exception. Concretely it mattered for one reachable case: a subclass wanting to
+pre-exempt a treasury address from its constructor had to restate both `require`s, with nothing forcing the
+event.
+
+Fixed by giving the exemptions the same ownership:
+
+```solidity
+function _addExemptAddress(address targetAddress) internal virtual {
+ require(targetAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
+ require(_addAddress(targetAddress), RuleAddressSet_AddressAlreadyListed());
+ emit ExemptAddressAdded(targetAddress);
+}
+```
+
+**Moving the guards into the helper is the point, not a side effect**: the zero-address and duplicate checks
+now cover every write path, including any future constructor. No behaviour change on the existing path — the
+37 unit tests pass unchanged, including the two that assert each `require`.
+
+The batch events keep their counters (`added` / `skipped`), which is the shape `C-4` of the main analysis
+established for the whole library.
+
+## D. Duplication
+
+### D-1. The revert-free read mirrors `TokenSupplyReader` — considered, declined
+
+`_balanceOf` has the same shape as `TokenSupplyReader._currentSupply`, and `_setBalanceToken`'s probe the
+same shape as `_probeTotalSupplyCallable`:
+
+```solidity
+try balanceToken.balanceOf(account) returns (uint256 b) { return (true, b); } catch { return (false, 0); }
+```
+
+**Declined, for the reason `D-2` of the main analysis gives for when extraction *is* right.** That extraction
+happened because two rules held *byte-identical* code. Here the functions differ — `balanceOf(address)` takes
+an argument and `totalSupply()` does not — so a shared base would need a hook per call shape, and only one
+rule uses this one. Extracting now would add indirection to remove nothing. Reconsider if a second
+balance-reading rule appears; at that point the precedent applies directly.
+
+### D-2. A tenth detect-then-`require` pair — consistent with the existing decision
+
+`_transferred` / `_transferredFrom` follow the pattern already reviewed as `D-3` and deliberately left, on the
+grounds that the per-rule custom error is the only variation and is worth keeping. This rule makes it ten
+instances. Recorded so the count in that finding stays accurate; no new decision.
+
+## E. `virtual` / override convention
+
+### E-1. Three functions not `virtual` — matches every sibling
+
+`canReturnTransferRestrictionCode`, `transferred(from,to,value)` and `transferred(spender,from,to,value)`
+carry `override` without `virtual`.
+
+Checked against `RuleMaxTotalSupplyBase`, `RuleChainlinkPoRBase`, `RuleBlacklistBase` and
+`RuleWhitelistBase`: **none** of them marks these three `virtual` either. The new rule is consistent with the
+library rather than introducing an inconsistency, which is the evidence the convention check weighs most.
+
+Everything else in the new code — every `internal`, every setter, every getter — is `virtual`, matching
+`CLAUDE.md`'s "all `internal` functions should be `virtual`" and the outcome of `E-1`/`E-3` in the main
+analysis.
+
+**Verdict: nothing to do here.** These three belong to the library-wide set called out as out of scope in
+`E-2` and should move together if they move at all.
+
+## F. Specification conformance
+
+### F-1. `IAddressList` is not advertised — keep it that way
+
+The rule inherits `RuleAddressSetInternal` for the exemption set but implements and advertises **no**
+`IAddressList` surface. That is worth stating explicitly, because the opposite would be an easy "improvement"
+to make and would be a real defect:
+
+`RuleWhitelistWrapperBase` discovers child rules by calling `IAddressList.areAddressesListed`. A rule that
+advertised `IAddressList` could be added to a wrapper, which would read its set as *"these addresses are
+allowed"* — the exact inverse of *"these addresses are exempt from a cap"*. Every exempt address would be
+treated as the only permitted address, and every other holder blocked.
+
+Advertising only `IRule` / `IERC1404Extend` / the compliance interfaces (inherited from
+`RuleTransferValidation`) keeps that mistake unavailable. **Verdict: keep, and this note is the reason.**
+
+### F-2. Restriction codes are unique — verified
+
+`82` and `83` are free across the whole library; the used set is
+`21–25, 30–32, 36–38, 46, 50–51, 55–57, 60–66, 70, 75–79, 81` plus the RuleEngine's `200/201`. Both are
+returned by `canReturnTransferRestrictionCode` and mapped by `messageForTransferRestriction`, and both are
+covered by tests.
+
+### F-3. `address(0)` could enter the exemption set — found and fixed during implementation
+
+`RuleAddressSetInternal._addAddress` does **not** guard the sentinel; each caller must, which
+`IdentityRegistryWhitelistBase` does explicitly. The first version of `addExemptAddress` omitted that guard,
+so the mint/burn sentinel could have been added to the exemption list — violating invariant `I-12` and
+polluting the emitted event with an address that is not a holder.
+
+Caught by `testAddExemptAddressRejectsZeroAddress` before the rule was complete. Both paths are now guarded
+and tested: the single add by an explicit `require`, the batch by the function pointer
+`_addAddresses` passes to `AddressSetBatchLib`, which rejects the whole batch.
+
+## G. Code / documentation agreement
+
+### G-1. Claims checked against the code — no mismatch
+
+Grepped the rule's documentation for testable claims and checked each: the code table (`82`/`83`), the
+who-is-screened matrix, the `maxBalance = 0` semantics, `remainingCapacity` returning `type(uint256).max` for
+exempt addresses and the burn sentinel, the batch conventions, the one-instance-per-token caveat, and the
+role names in the methods table. All hold.
+
+The documented limitation — that the cap is per address and bypassable by splitting a position — is asserted
+end to end by `testSplitWalletsBypassTheCapEvenWithAWhitelist`, which deliberately admits both wallets of one
+investor and shows the combined holding reaching twice the cap with a whitelist active. The documentation and
+the test therefore cannot drift apart silently.
+
+## H. Behaviour at odds with the purpose
+
+### H-1. The cap depended, silently, on *when* the token notifies — documented and pinned
+
+The check is `balanceOf(to) + value <= maxBalance`. That is correct **only while `balanceOf(to)` still
+excludes `value`** — i.e. only if the token calls the compliance hook *before* it moves the tokens.
+
+CMTAT does:
+
+```solidity
+// CMTAT, 0_CMTATBaseCommon.sol
+function transfer(address to, uint256 value) public virtual override returns (bool) {
+ address from = _msgSender();
+ _checkTransferred(address(0), from, to, value); // <- compliance first
+ ERC20Upgradeable._transfer(from, to, value); // <- balances after
+```
+
+So the rule is correct as shipped. But nothing in the rule said so, and nothing failed if it stopped being
+true. A token that notified compliance *after* crediting the receiver would double-count `value`, **halving
+the effective cap** and rejecting a transfer that exactly reaches it — a silent, plausible-looking
+off-by-a-factor rather than an obvious break.
+
+Fixed two ways. The assumption is now stated in the contract NatSpec, naming the CMTAT call order. And it is
+pinned by a test that mints exactly the cap:
+
+```solidity
+function testMintExactlyToTheCapProvesPreUpdateAccounting() public {
+ cmtatContract.mint(INVESTOR, CAP);
+ assertEq(cmtatContract.balanceOf(INVESTOR), CAP, "a mint of exactly the cap must succeed");
+```
+
+**The guard was verified, not assumed.** Mutating `_detectTransferRestriction` to simulate post-update
+accounting (`balance += value`) makes it fail with exactly the predicted symptom:
+
+```
+[FAIL: RuleMaxBalance_InvalidTransferFrom(..., 1000, 82)] testMintExactlyToTheCapProvesPreUpdateAccounting()
+```
+
+A mint of exactly the cap rejected with code `82` — the halved cap, caught.
+
+### H-2. `remainingCapacity` returns `OK` with zero headroom — left, and documented
+
+For a holder already at or above the cap, `remainingCapacity` returns `(TRANSFER_OK, 0)`. The code describes
+whether the *query* could be answered, not whether a transfer would succeed; a caller reading only the code
+could misread "0 headroom" as "fine to proceed".
+
+**Left as is.** The two-value return is the answer: the caller must look at `headroom`, and returning a
+non-zero restriction code for a perfectly readable balance would be worse — it would make a diagnostic view
+report a failure that has not been attempted. The NatSpec says the code is `0` "when the headroom is
+meaningful". Recorded here rather than changed.
+
+---
+
+## What was measured
+
+- **B-1** — `detectTransferRestriction`, both paths, current order vs reordered, toggled in place on the real
+ contract and re-run through one harness: 14 995 / 2 995 versus 12 684 / 12 684.
+- **H-1** — mutation of the balance comparison to simulate post-update accounting; the new test fails with
+ code `82` and passes when reverted.
+- Coverage after the session: `RuleMaxBalanceBase` 98.82% lines, **100% statements, 100% branches**;
+ both deployment variants 100% across the board. The single uncovered line is the abstract
+ `_authorizeMaxBalanceManager` declaration, which no test can execute because only the override runs.
+
+All 820 tests pass on the default profile and 31 on the ERC-3643 profile.
diff --git a/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md
new file mode 100644
index 00000000..8bd65591
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md
@@ -0,0 +1,490 @@
+# Claude Code Analysis — Deployment Scripts
+
+Report version: `v0.5.0`
+Tool: **Claude Code** (Anthropic) — interactive review and implementation session, model Opus 5
+Scope: the four Foundry deployment scripts in `script/` and their tests under `test/DeploymentScripts/`.
+`src/` is covered separately by [`CLAUDE_ANALYSIS.md`](./CLAUDE_ANALYSIS.md); `lib/` is out of scope.
+
+The review axes are the ones used for that companion report: correctness, duplication, configuration,
+events and observability, documentation, test coverage, and behaviour that is technically correct but does
+not serve the purpose of the code.
+
+**Status: all 12 findings implemented.** Each is marked in the summary table below, and
+[What was implemented](#what-was-implemented) records how the central fix was verified.
+
+**None of these are on-chain vulnerabilities.** Scripts are off-chain tooling; they hold no funds and are
+not deployed. Severities describe how badly each one damages the script's usefulness, not exploitability.
+
+## Inventory
+
+State when the review was written, and after the fixes:
+
+| Script | Topology | Tests (before → after) | Runs under `forge script`? (before → after) |
+| --- | --- | --- | --- |
+| `DeployCMTATWithBlacklist` | B (rule bound directly) | 1 → 6 | ❌ reverts → ✅ |
+| `DeployCMTATWithWhitelist` | B (rule bound directly) | 1 → 8 | ❌ reverts → ✅ |
+| `DeployCMTATWithBlacklistAndSanctionsList` | A (RuleEngine) | 18 → 18 | ❌ reverts → ✅ |
+| `DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply` | A (RuleEngine) | 19 → 19 | ✅ → ✅ |
+
+All four now run. Suite total went from 745 to 756 tests.
+
+## Summary
+
+All twelve findings are implemented.
+
+| ID | Severity | Finding | Files | Status |
+| --- | --- | --- | --- | --- |
+| [S-1](#s-1) | **Blocker** | Three of four scripts revert under `forge script`: `address(this)` inside a broadcast | 1, 2, 3 | ✅ Fixed |
+| [S-2](#s-2) | **High** | The tests structurally cannot catch S-1, and `run()` is untestable from `forge test` at all | all | ✅ Fixed (CI step) |
+| [S-3](#s-3) | Medium | `DeployCMTATWithWhitelist` produces a token that cannot be minted | 2 | ✅ Fixed |
+| [S-4](#s-4) | Medium | The CMTAT constructor block is copy-pasted four times | all | ✅ Fixed |
+| [S-5](#s-5) | Medium | Every parameter is hard-coded; no environment configuration | all | ✅ Fixed |
+| [S-6](#s-6) | Low | `forwarder` reaches the token but is silently dropped for the rules | 1, 2, 3 | ✅ Fixed |
+| [S-7](#s-7) | Low | `bytes32(0)` written out instead of `DEFAULT_ADMIN_ROLE` (12 sites) | 1, 2, 3, 4 | ✅ Fixed |
+| [S-8](#s-8) | Low | Deployed addresses are never logged | all | ✅ Fixed |
+| [S-9](#s-9) | Low | Scripts 1 and 2 carry no NatSpec at all | 1, 2 | ✅ Fixed |
+| [S-10](#s-10) | Low | The one assertion in scripts 1 and 2 skips the admin hand-over | 1, 2 | ✅ Fixed |
+| [S-11](#s-11) | Info | Only script 4 warns that an unset sanctions oracle fails open | 3 | ✅ Fixed |
+| [S-12](#s-12) | Info | The set silently mixes both integration topologies | all | ✅ Fixed |
+
+Two candidate findings were checked and **dismissed**; they are recorded in
+[Checked and not a problem](#checked-and-not-a-problem) so the negative results are not lost.
+
+---
+
+## S-1
+
+**Three of four scripts revert under `forge script`.**
+
+Severity: **Blocker**. These scripts cannot deploy anything. Verified by running each one:
+
+```
+$ forge script script/DeployCMTATWithBlacklist.s.sol:DeployCMTATWithBlacklist
+└─ ← [Revert] Usage of `address(this)` detected in script contract.
+ Script contracts are ephemeral and their addresses should not be relied upon.
+Error: script failed
+```
+
+Same for `DeployCMTATWithWhitelist` and `DeployCMTATWithBlacklistAndSanctionsList`.
+`DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply` reports `Script ran successfully.`
+
+The cause is the same in all three: the script uses `address(this)` as the account that performs the
+deployment and holds the temporary admin roles.
+
+```solidity
+token = new CMTATStandardStandalone(forwarder, address(this), /* ... */);
+// ...
+if (admin != address(this)) {
+ token.grantRole(bytes32(0), admin);
+ token.renounceRole(bytes32(0), address(this));
+}
+```
+
+Under `forge script` the calls are made by the **broadcaster**, not by the script contract, so the two
+identities disagree. Foundry rejects the read outright rather than let a script depend on an address that
+changes between simulation and broadcast.
+
+**Mitigating factor:** the revert happens during simulation, before anything is broadcast. There is no
+partial deployment and no orphaned contract, so the failure mode is a wasted invocation, not a stuck token.
+
+**Fix.** Take the deployer as an explicit parameter. Script 4 already does exactly this and is the reason it
+passes; the pattern transfers unchanged:
+
+```solidity
+function deploy(address admin, address deployer, /* ... */) public returns (/* ... */) {
+ token = new CMTATStandardStandalone(forwarder, deployer, /* ... */);
+ // ...
+ if (admin != deployer) {
+ token.grantRole(DEFAULT_ADMIN_ROLE, admin);
+ token.renounceRole(DEFAULT_ADMIN_ROLE, deployer);
+ }
+}
+
+function run() external returns (/* ... */) {
+ vm.startBroadcast();
+ // msg.sender is the broadcaster, which is also the account making every call below
+ deploy(msg.sender, msg.sender, /* ... */);
+ vm.stopBroadcast();
+}
+```
+
+Existing tests keep working: they call `deploy()` directly and can pass `address(this)` themselves.
+
+---
+
+## S-2
+
+**The tests cannot catch S-1, and `run()` cannot be tested from `forge test` at all.**
+
+Severity: **High**, because this is what let a blocker sit in three scripts while the suite stayed green.
+
+Every script test exercises `deploy()` directly:
+
+```solidity
+(token, rule) = script.deploy(address(1), address(0));
+```
+
+`deploy()` is a plain function call with no broadcast context, so `address(this)` resolves to the script
+contract and behaves sensibly. The guard that fires under `forge script` is never reached. The tests are not
+weak here so much as aimed at a different execution model than the one the script is used in.
+
+The obvious repair is to call `run()` from a test instead. **It does not work**, and it is worth recording why,
+because the failure is not obvious and someone will otherwise try it again:
+
+| Attempt | Result |
+| --- | --- |
+| `script.run()` from a test | Fails, but with `AccessControlUnauthorizedAccount` rather than the real error. `msg.sender` inside `run()` is the test contract, while broadcast attributes calls to `DEFAULT_SENDER`; the mismatch produces a role failure that is an artefact of the harness. |
+| `vm.prank(DEFAULT_SENDER); script.run()` | Prank is consumed by the preceding `new Script()` (a CREATE), so nothing changes. |
+| Construct the script first, then prank, then `run()` | `vm.startBroadcast: you have an active prank; broadcasting and pranks are not compatible` |
+
+Foundry refuses to combine a prank with a broadcast, so a test can never present itself to `run()` as the
+broadcaster. Confirming the point: script 4, which **does** work under `forge script`, fails all three of
+these attempts too. A test built this way would report the working script as broken.
+
+**Fix.** The only faithful harness is `forge script` itself, in CI. It needs no network and no key:
+
+```yaml
+- name: Deployment scripts (dry run)
+ run: |
+ for s in script/*.s.sol; do
+ name=$(basename "$s" .s.sol)
+ forge script "$s:$name"
+ done
+```
+
+This is currently absent. `.github/workflows/test.yml` runs `forge build --sizes` and `forge test` on both
+profiles; `forge build` compiles the scripts but never executes them, which is why a runtime-only guard slipped
+through. Keep the existing unit tests for wiring and role assertions, and add this step for the execution model.
+
+---
+
+## S-3
+
+**`DeployCMTATWithWhitelist` deploys a token that cannot be minted.**
+
+Severity: Medium. Recoverable, but the script's output does not do the first thing an issuer needs.
+
+```solidity
+rule = new RuleWhitelist(admin, address(0), checkSpender, false);
+// ^^^^^ allowMintBurn
+```
+
+With `allowMintBurn = false`, mint is rejected regardless of the whitelist. Measured on a token deployed by
+this script, minting to an investor who **is** whitelisted:
+
+```
+MINT REVERTED
+detectTransferRestriction(address(0) -> investor): 24 // CODE_MINT_NOT_ALLOWED
+```
+
+This is the rule behaving exactly as designed (invariant I-12: mint permission is an explicit flag, and
+`address(0)` never enters the list). The problem is the script choosing the restrictive value silently, with
+no comment, no parameter, and no test covering issuance. An operator following the script gets a
+whitelist-gated token that cannot be issued, and the restriction code points at the rule rather than at the
+deployment choice.
+
+The state is recoverable: `setAllowMint(true)` and `setAllowBurn(true)` exist on `RuleWhitelistShared`,
+guarded by `onlyMintBurnManager`. So this is friction and a support burden, not a bricked deployment.
+
+**Fix.** Take `allowMintBurn` as a `deploy()` parameter, default it to `true` in `run()`, and add a test that
+mints to a whitelisted address after deployment. Whichever default is chosen, state it in a comment: the value
+determines whether the token can be issued at all.
+
+---
+
+## S-4
+
+**The CMTAT constructor block is copy-pasted four times.**
+
+Severity: Medium (maintenance).
+
+All four scripts open with the same twelve lines, identical down to the document hash:
+
+```solidity
+ICMTATConstructor.ERC20Attributes memory erc20Attributes =
+ ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
+ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
+ ICMTATConstructor.ExtraInformationAttributes(
+ "CMTAT_ISIN",
+ IERC1643CMTAT.DocumentInfo(
+ "Terms", "https://cmta.ch", 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b
+ ),
+ "CMTAT_info"
+ );
+ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
+```
+
+Each of `"CMTA Token"`, `"CMTAT"`, `"CMTAT_ISIN"`, the URL and the hash appears in four places. A CMTAT
+constructor change means four edits, and the failure mode of missing one is a script that still compiles.
+The pattern also spread as the newest script was written from the previous one, so it grows with the
+directory.
+
+**Fix.** A shared base contract, which also gives the metadata a single place to be configured (S-5):
+
+```solidity
+// script/base/CMTATDeploymentBase.sol
+abstract contract CMTATDeploymentBase is Script {
+ function _defaultTokenAttributes()
+ internal
+ pure
+ virtual
+ returns (ICMTATConstructor.ERC20Attributes memory, ICMTATConstructor.ExtraInformationAttributes memory)
+ { /* the block above */ }
+}
+```
+
+Scripts then inherit and call it, and a script needing different metadata overrides one function.
+
+---
+
+## S-5
+
+**Every parameter is hard-coded; there is no environment configuration.**
+
+Severity: Medium (usability).
+
+No script reads `vm.envAddress`, `vm.envUint` or `vm.envString`; there are zero occurrences across the
+directory. `run()` bakes in every value it does not take from `msg.sender`:
+
+| Value | Hard-coded as | Consequence |
+| --- | --- | --- |
+| Token name / symbol | `"CMTA Token"` / `"CMTAT"` | Every deployment is named after the example |
+| Decimals | `0` | Correct for CMTA equity, wrong for most other instruments |
+| ISIN, document URL, document hash | example constants | Ships placeholder legal metadata on-chain |
+| Forwarder | `address(0)` | Meta-transactions cannot be enabled without editing source |
+| Sanctions oracle | `address(0)` | Screening disabled, see S-11 |
+| Max total supply (script 4) | `1_000_000` | Cap unrelated to the actual issuance |
+
+Any real deployment means editing the script, which puts a local modification in the way of every use and
+makes the committed version a template rather than a tool.
+
+**Fix.** Read from the environment with documented fallbacks, so the scripts stay runnable out of the box:
+
+```solidity
+string memory name = vm.envOr("CMTAT_NAME", string("CMTA Token"));
+uint256 cap = vm.envOr("CMTAT_MAX_SUPPLY", uint256(1_000_000));
+address oracle = vm.envOr("SANCTIONS_ORACLE", address(0));
+```
+
+`vm.envOr` keeps the current behaviour when nothing is set, so this is additive. Pair it with a short table in
+the README listing the variables.
+
+---
+
+## S-6
+
+**`forwarder` reaches the token but is silently dropped for the rules.**
+
+Severity: Low (inconsistency).
+
+Scripts 1 to 3 accept a `forwarder` argument, pass it to the token and to the `RuleEngine`, then hard-code
+`address(0)` for every rule:
+
+```solidity
+// script 3: forwarder honoured here...
+ruleEngine = new RuleEngine(address(this), forwarder, address(token));
+// ...and discarded here
+ruleBlacklist = new RuleBlacklist(admin, address(0));
+ruleSanctionsList = new RuleSanctionsList(admin, address(0), sanctionsOracle);
+```
+
+Script 4 passes `forwarder` through to the rules instead. So the four scripts disagree, and neither behaviour
+is documented.
+
+The effect is narrow but real. A rule's forwarder governs meta-transactions on its **admin** surface, which is
+list management (`addAddress`, `removeAddress`, `setSanctionListOracle`). With `address(0)`, an operator who
+set up ERC-2771 for the token finds that whitelist and blacklist maintenance still requires a funded key,
+which is usually the opposite of the intent.
+
+**Fix.** Pass `forwarder` through consistently, as script 4 does. If some rule should deliberately not accept
+meta-transactions, say so in a comment at that call site rather than leaving a bare `address(0)`.
+
+---
+
+## S-7
+
+**`bytes32(0)` instead of `DEFAULT_ADMIN_ROLE`.**
+
+Severity: Low (readability). Twelve sites across all four scripts:
+
+```solidity
+token.grantRole(bytes32(0), admin);
+token.renounceRole(bytes32(0), address(this));
+```
+
+`AccessControl.DEFAULT_ADMIN_ROLE` is a public constant equal to `0x00`, so the two compile identically. The
+literal makes the most security-relevant lines in each script the least readable, and a reviewer scanning for
+role hand-over has nothing to grep for. Same value, named:
+
+```solidity
+token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+```
+
+---
+
+## S-8
+
+**Deployed addresses are never logged.**
+
+Severity: Low (observability). No script imports `console`.
+
+`forge script` prints the return values, so the addresses are recoverable from the run output, and the
+broadcast JSON under `broadcast/` records them. But the output is positional: script 4 returns five contracts
+as an unlabelled tuple, and matching each address to its role means reading the signature. A few lines make
+the run self-describing and the terminal output copy-pasteable into a deployment record:
+
+```solidity
+console.log("CMTAT token ", address(token));
+console.log("RuleEngine ", address(ruleEngine));
+console.log("RuleBlacklist ", address(ruleBlacklist));
+```
+
+---
+
+## S-9
+
+**Scripts 1 and 2 carry no NatSpec.**
+
+Severity: Low (documentation). Measured comment tags per script:
+
+| Script | NatSpec tags |
+| --- | --- |
+| `DeployCMTATWithBlacklist` | 0 |
+| `DeployCMTATWithWhitelist` | 0 |
+| `DeployCMTATWithBlacklistAndSanctionsList` | 2 (a title block, no `@param`) |
+| `DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply` | 19 |
+
+Scripts 1 and 2 document neither the parameters, nor the deployment order, nor the fact that they bind the
+rule directly rather than through a RuleEngine (S-12). `checkSpender` and `allowMintBurn` in the whitelist
+script are two bare booleans at a call site, one of which decides whether the token can be issued (S-3).
+
+Per the project convention, use `/** */` blocks rather than `///`.
+
+---
+
+## S-10
+
+**The single assertion in scripts 1 and 2 skips the admin hand-over.**
+
+Severity: Low (test coverage). The whole test is:
+
+```solidity
+(CMTATStandardStandalone token, RuleBlacklist rule) = _deploy(script);
+assertEq(address(token.ruleEngine()), address(rule));
+```
+
+Coverage across the directory is lopsided:
+
+| Test file | Tests | Assertions |
+| --- | --- | --- |
+| `DeployCMTATWithBlacklist.t.sol` | 1 | 1 |
+| `DeployCMTATWithWhitelist.t.sol` | 1 | 1 |
+| `DeployCMTATWithBlacklistAndSanctionsList.t.sol` | 18 | 13 |
+| `DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.t.sol` | 19 | 35 |
+
+The assertion checks the wiring, which is the easy half. It does not check the part that matters most: that
+`admin` ended up with `DEFAULT_ADMIN_ROLE` and that the deployer no longer holds it. That branch does execute
+in the test, since `admin` is `address(1)` and the script contract is not, so a hand-over that silently failed
+would leave the deployer as a permanent admin and the test would still pass.
+
+**Fix.** Two assertions per script, matching what scripts 3 and 4 already do:
+
+```solidity
+assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), admin));
+assertFalse(token.hasRole(token.DEFAULT_ADMIN_ROLE(), address(script)));
+```
+
+Add the mint path from S-3 to the whitelist test while there.
+
+---
+
+## S-11
+
+**Only script 4 warns that an unset sanctions oracle fails open.**
+
+Severity: Info. Script 3 says what the parameter does, not what it costs:
+
+```solidity
+// Pass address(0) for sanctionsOracle to deploy without an oracle configured.
+// The oracle can be set post-deployment via RuleSanctionsList.setSanctionListOracle().
+```
+
+Accurate, and it omits that until that call happens `RuleSanctionsList` passes **every** transfer. The
+deployment looks complete, the rule is registered in the engine and reports no error, and screening is off.
+Script 4 states this in both `@param` and `@dev`. Worth carrying over verbatim, since the default value is the
+unsafe one and the script is what an operator reads.
+
+---
+
+## S-12
+
+**The set silently mixes both integration topologies.**
+
+Severity: Info. Scripts 1 and 2 bind the rule straight to the token:
+
+```solidity
+token.setRuleEngine(IRuleEngine(address(rule))); // Topology B
+```
+
+Scripts 3 and 4 go through a `RuleEngine`. Both are supported and documented in `CLAUDE.md`, and the choice
+changes what `msg.sender` is inside a rule, which matters as soon as an operation rule is added. Neither
+script mentions which one it uses or why.
+
+For the validation rules deployed here the distinction is harmless. It stops being harmless the moment someone
+copies script 1 as the starting point for a deployment involving `RuleConditionalTransferLightMultiToken`,
+which is direct-binding-only, or `RuleMintAllowance`, which is not. One line of NatSpec per script naming the
+topology would make the scripts self-documenting on the point most likely to be got wrong.
+
+---
+
+## Checked and not a problem
+
+Recorded so the negative results are not re-investigated.
+
+**`admin == address(0)` does not brick a deployment.** The hand-over grants to `admin` then renounces, so a
+zero admin would in principle leave a contract with no administrator. It cannot happen: the rule constructor
+rejects it first.
+
+```
+deploy(address(0), address(0))
+ → revert AccessControlModuleStandalone_AddressZeroNotAllowed()
+```
+
+No guard needs to be added to the scripts.
+
+**Script 4's two remaining `address(this)` occurrences are prose.** Both sit inside the `@dev` block that
+explains why the deployer is passed explicitly, not in executable code. Confirmed by the successful
+`forge script` run.
+
+## What was implemented
+
+`script/base/CMTATDeploymentBase.sol` is new: it holds the shared token metadata (S-4), the environment
+configuration (S-5), and the `_logDeployment` helper (S-8). All four scripts inherit it.
+
+**The regression guard was verified rather than assumed.** With `address(this)` reintroduced into
+`DeployCMTATWithBlacklist.run()`:
+
+| Check | Result |
+| --- | --- |
+| `forge test` on that script's suite | 6 passed, 0 failed — the bug is invisible |
+| The new `forge script` dry-run step | **fails** |
+
+That is the split the CI step exists for, and it confirms S-2's central claim: no unit test covers this,
+because Foundry will not let one run `run()` in a broadcast context.
+
+Environment configuration was checked against a live deploy: with nothing set the token is still
+`CMTA Token` / `CMTAT`, and with `CMTAT_NAME` / `CMTAT_SYMBOL` set it picks them up.
+
+## Suggested order of work
+
+*(Kept for the record; all of it is now done.)*
+
+1. **S-1** — three scripts do not work. Everything else is cosmetic next to it.
+2. **S-2** — add the CI dry-run step in the same change, so S-1 cannot recur silently.
+3. **S-3** — a whitelist token that cannot be minted will be reported as a bug in the rule.
+4. **S-10 and S-9** — cheap, and they make the remaining work safer to do.
+5. **S-4 and S-5** — the shared base and environment configuration land naturally together.
+6. **S-6, S-7, S-8, S-11, S-12** — consistency and documentation.
+
+Items 1 to 3 are the ones that change whether the scripts work. The rest change how pleasant they are to
+maintain.
diff --git a/doc/security/audits/tools/v0.5.0/aderyn-report-feedback.md b/doc/security/audits/tools/v0.5.0/aderyn-report-feedback.md
new file mode 100644
index 00000000..dc24521c
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/aderyn-report-feedback.md
@@ -0,0 +1,49 @@
+# Aderyn `v0.5.0` — triage
+
+```bash
+aderyn -x mocks --output doc/security/audits/tools/v0.5.0/aderyn-report.md
+```
+
+Tool: **Aderyn 0.6.5** · Compiler: solc `0.8.36` · Run date: **2026-08-13**
+Scope: production contracts only, mocks excluded via `-x mocks`. **3 942 nSLOC.**
+**0 High · 9 Low categories, 336 instances.**
+
+This run supersedes the earlier `v0.5.0` runs and was made after the cap-manager split
+(`TotalSupplyCapManager`, `BalanceCapManager`).
+
+**Executive triage: nothing to fix.** Aderyn reports no High or Medium finding. Every Low category is a false
+positive, a by-design pattern, an environment note, or cosmetic. **No new category appeared**; the instance
+growth is exactly the two files added by the cap-manager split. Aderyn scopes to project sources from the Foundry config,
+so no `lib/` citation appears (verified: `grep -c 'lib/' aderyn-report.md` → 0).
+
+## Per-category triage
+
+| ID | Finding | Instances | Disposition | Reason (verified against source) |
+|---|---|---|---|---|
+| L-1 | Centralization Risk | 80 | **By design** | Every privileged action is an intentional operator capability: list management, oracle configuration, supply and balance caps, exemptions, approvals. Each is documented per rule under Access Control, and the trust model is stated in the audit report. A compliance rule without a privileged operator would not do its job. |
+| L-2 | Unspecific Solidity Pragma | 87 | **By design** | A library must float `^0.8.20` so integrators can pin their own compiler. This repository's own builds are pinned by `foundry.toml` (0.8.36). Pinning in source would force every consumer onto one compiler. |
+| L-3 | Address State Variable Set Without Checks | 3 | **False positive** | Verified against each cited file. `TotalSupplyCapManager` and `ChainlinkPoRFeedManager` both validate before assigning — non-zero, has-code, and the required call answerable — through `_validateTokenContract` / `_setReservesFeed`, which Aderyn does not follow into. `RuleSanctionsListBase` is a different case: its internal `_setSanctionListOracle` assigns unguarded **on purpose**, because `clearSanctionListOracle` legitimately writes `address(0)` to disable screening; the non-zero check lives in `setSanctionListOracle` and in the constructor, which only calls it for a non-zero oracle. |
+| L-4 | Literal Instead of Constant | 2 | **Cosmetic** | The `10` in `10 ** (to - from)` is the decimal base of a scaling conversion, not a magic number. Naming it would not make the expression clearer. |
+| L-5 | PUSH0 Opcode | 89 | **Environment** | The repo targets the `prague` EVM, where PUSH0 exists. Relevant only when deploying to a chain that predates Shanghai, which is a deployer decision, not a code defect. |
+| L-6 | Modifier Invoked Only Once | 1 | **By design** | `RuleWhitelistShared`'s mint/burn manager modifier is the extension point subclasses override; single use in the base is the pattern, not an accident. |
+| L-7 | Empty Block | 70 | **By design** | `_authorize*` hooks whose entire body is the `onlyRole(...)` modifier, plus constructor pass-throughs. The project convention makes these hooks `internal view virtual` so the check is compiler-enforced; the body is deliberately empty. |
+| L-8 | Costly operations inside loop | 3 | **By design** | Batch add/remove must write per element — that is what a batch operation is. Two are in `AddressSetBatchLib`, the shared implementation; the third is `RuleMintAllowanceBase`. |
+| L-9 | Unchecked Return | 1 | **By design** | `AccessControlModuleStandalone` ignores `_grantRole`'s boolean, which reports only whether the role was newly granted. Granting a role the account already holds is an intended no-op. |
+
+## Delta from the previous run
+
+| | Previous (pre cap-manager split) | This run | Δ |
+|---|---|---|---|
+| nSLOC | 3 915 | **3 942** | +27 |
+| Categories | 9 | **9** | — |
+| Instances | 332 | **336** | +4 |
+
+The entire increase is **two new files**: `TotalSupplyCapManager` and `BalanceCapManager` each add one
+`Unspecific Solidity Pragma` and one `PUSH0 Opcode` instance. Every other category — including the four that
+describe behaviour (`Address State Variable Set Without Checks`, `Costly operations inside loop`,
+`Unchecked Return`, `Modifier Invoked Only Once`) and the two largest (`Centralization Risk` 80,
+`Empty Block` 70) — is identical instance-for-instance.
+
+That is what a pure code move should look like: per-file categories track the file count, and nothing
+behavioural shifts. `Centralization Risk` staying at 80 is the sharpest signal, since the setters moved from the
+bases into the managers without any being added or removed.
diff --git a/doc/security/audits/tools/v0.5.0/aderyn-report.md b/doc/security/audits/tools/v0.5.0/aderyn-report.md
new file mode 100644
index 00000000..159f2a77
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/aderyn-report.md
@@ -0,0 +1,2281 @@
+# Aderyn Report — `v0.5.0`
+
+```bash
+aderyn -x mocks --output doc/security/audits/tools/v0.5.0/aderyn-report.md
+```
+
+Tool: **Aderyn 0.6.5** · Scope: production contracts only (**mocks excluded** via `-x mocks`) · 3 942 nSLOC
+Compiler: solc `0.8.36` · Run date: 2026-08-13, after the cap-manager split (supersedes the earlier `v0.5.0` runs)
+
+**Result: 0 High · 9 Low categories (336 instances). Nothing to fix** — every category is a false positive,
+a by-design pattern, or cosmetic. Verified line-by-line in the
+[feedback file](./aderyn-report-feedback.md).
+
+| ID | Finding | Severity | Instances | Δ vs previous run | Assessment |
+|---|---|---|---|---|---|
+| L-1 | Centralization Risk | Low | 80 | — | By design — every privileged action is an intentional operator capability, documented per rule under Access Control |
+| L-2 | Unspecific Solidity Pragma | Low | 87 | **+2** | By design — a library must float `^0.8.20` so integrators can pin; `foundry.toml` pins 0.8.36 for this repo's own builds |
+| L-3 | Address State Variable Set Without Checks | Low | 3 | — | **False positive** — every cited assignment is preceded by validation Aderyn does not follow into |
+| L-4 | Literal Instead of Constant | Low | 2 | — | Cosmetic — the `10` in `10 ** (to - from)` is the decimal base, not a magic number |
+| L-5 | PUSH0 Opcode | Low | 89 | **+2** | Environment — the repo targets `prague`; only relevant on chains without PUSH0 |
+| L-6 | Modifier Invoked Only Once | Low | 1 | — | By design — the modifier is the extension point subclasses override |
+| L-7 | Empty Block | Low | 70 | — | By design — `_authorize*` hooks whose body is the `onlyRole` modifier, plus constructor pass-throughs |
+| L-8 | Costly operations inside loop | Low | 3 | — | By design — batch add/remove must write per element; concentrated in `AddressSetBatchLib` |
+| L-9 | Unchecked Return | Low | 1 | — | By design — one `_grantRole` return in `AccessControlModuleStandalone` |
+
+Overview: [`AUDIT_OVERVIEW.md`](../../AUDIT_OVERVIEW.md)
+
+---
+
+# Aderyn Analysis Report
+
+This report was generated by [Aderyn](https://github.com/Cyfrin/aderyn), a static analysis tool built by [Cyfrin](https://cyfrin.io), a blockchain security company. This report is not a substitute for manual audit or security review. It should not be relied upon for any purpose other than to assist in the identification of potential security vulnerabilities.
+# Table of Contents
+
+- [Summary](#summary)
+ - [Files Summary](#files-summary)
+ - [Files Details](#files-details)
+ - [Issue Summary](#issue-summary)
+- [Low Issues](#low-issues)
+ - [L-1: Centralization Risk](#l-1-centralization-risk)
+ - [L-2: Unspecific Solidity Pragma](#l-2-unspecific-solidity-pragma)
+ - [L-3: Address State Variable Set Without Checks](#l-3-address-state-variable-set-without-checks)
+ - [L-4: Literal Instead of Constant](#l-4-literal-instead-of-constant)
+ - [L-5: PUSH0 Opcode](#l-5-push0-opcode)
+ - [L-6: Modifier Invoked Only Once](#l-6-modifier-invoked-only-once)
+ - [L-7: Empty Block](#l-7-empty-block)
+ - [L-8: Costly operations inside loop](#l-8-costly-operations-inside-loop)
+ - [L-9: Unchecked Return](#l-9-unchecked-return)
+
+
+# Summary
+
+## Files Summary
+
+| Key | Value |
+| --- | --- |
+| .sol Files | 89 |
+| Total nSLOC | 3942 |
+
+
+## Files Details
+
+| Filepath | nSLOC |
+| --- | --- |
+| src/modules/AccessControlModuleStandalone.sol | 24 |
+| src/modules/MetaTxModuleStandalone.sol | 6 |
+| src/modules/Ownable2StepERC165Module.sol | 11 |
+| src/modules/VersionModule.sol | 8 |
+| src/registry/IdentityRegistryWhitelist.sol | 7 |
+| src/registry/abstract/IdentityRegistryWhitelistBase.sol | 52 |
+| src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol | 6 |
+| src/registry/interfaces/IIdentityRegistryERC3643.sol | 10 |
+| src/rules/interfaces/AggregatorV3Interface.sol | 14 |
+| src/rules/interfaces/IAddressList.sol | 15 |
+| src/rules/interfaces/IBalanceOf.sol | 4 |
+| src/rules/interfaces/IDecimals.sol | 4 |
+| src/rules/interfaces/IERC2980.sol | 5 |
+| src/rules/interfaces/IERC7943NonFungibleCompliance.sol | 19 |
+| src/rules/interfaces/IIdentityRegistry.sol | 7 |
+| src/rules/interfaces/ISanctionsList.sol | 4 |
+| src/rules/interfaces/ITotalSupply.sol | 4 |
+| src/rules/interfaces/ITransferContext.sol | 22 |
+| src/rules/interfaces/library/AddressListInterfaceId.sol | 4 |
+| src/rules/operation/RuleConditionalTransferLight.sol | 40 |
+| src/rules/operation/RuleConditionalTransferLightMultiToken.sol | 33 |
+| src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol | 32 |
+| src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol | 33 |
+| src/rules/operation/RuleMintAllowance.sol | 34 |
+| src/rules/operation/RuleMintAllowanceOwnable2Step.sol | 27 |
+| src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol | 70 |
+| src/rules/operation/abstract/RuleConditionalTransferLightBase.sol | 144 |
+| src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol | 24 |
+| src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol | 242 |
+| src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol | 27 |
+| src/rules/operation/abstract/RuleMintAllowanceBase.sol | 142 |
+| src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol | 14 |
+| src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol | 31 |
+| src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol | 67 |
+| src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol | 38 |
+| src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol | 6 |
+| src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol | 5 |
+| src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol | 14 |
+| src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol | 21 |
+| src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol | 65 |
+| src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol | 39 |
+| src/rules/validation/abstract/base/RuleBlacklistBase.sol | 104 |
+| src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol | 99 |
+| src/rules/validation/abstract/base/RuleERC2980Base.sol | 247 |
+| src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol | 128 |
+| src/rules/validation/abstract/base/RuleMaxBalanceBase.sol | 84 |
+| src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol | 79 |
+| src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol | 65 |
+| src/rules/validation/abstract/base/RuleSanctionsListBase.sol | 108 |
+| src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol | 58 |
+| src/rules/validation/abstract/base/RuleWhitelistBase.sol | 64 |
+| src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol | 149 |
+| src/rules/validation/abstract/core/BalanceCapManager.sol | 98 |
+| src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol | 111 |
+| src/rules/validation/abstract/core/RuleNFTAdapter.sol | 118 |
+| src/rules/validation/abstract/core/RuleTransferValidation.sol | 70 |
+| src/rules/validation/abstract/core/RuleWhitelistShared.sol | 96 |
+| src/rules/validation/abstract/core/TokenSupplyReader.sol | 19 |
+| src/rules/validation/abstract/core/TotalSupplyCapManager.sol | 45 |
+| src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol | 32 |
+| src/rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol | 18 |
+| src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol | 22 |
+| src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol | 17 |
+| src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol | 12 |
+| src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol | 18 |
+| src/rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol | 4 |
+| src/rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol | 9 |
+| src/rules/validation/deployment/RuleBlacklist.sol | 33 |
+| src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol | 31 |
+| src/rules/validation/deployment/RuleChainlinkPoR.sol | 29 |
+| src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol | 27 |
+| src/rules/validation/deployment/RuleERC2980.sol | 34 |
+| src/rules/validation/deployment/RuleERC2980Ownable2Step.sol | 35 |
+| src/rules/validation/deployment/RuleIdentityRegistry.sol | 22 |
+| src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol | 23 |
+| src/rules/validation/deployment/RuleMaxBalance.sol | 22 |
+| src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol | 23 |
+| src/rules/validation/deployment/RuleMaxTotalSupply.sol | 22 |
+| src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol | 23 |
+| src/rules/validation/deployment/RuleReceiverWhitelist.sol | 33 |
+| src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol | 34 |
+| src/rules/validation/deployment/RuleSanctionsList.sol | 34 |
+| src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol | 35 |
+| src/rules/validation/deployment/RuleSpenderWhitelist.sol | 33 |
+| src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol | 34 |
+| src/rules/validation/deployment/RuleWhitelist.sol | 36 |
+| src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol | 36 |
+| src/rules/validation/deployment/RuleWhitelistWrapper.sol | 54 |
+| src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol | 41 |
+| **Total** | **3942** |
+
+
+## Issue Summary
+
+| Category | No. of Issues |
+| --- | --- |
+| High | 0 |
+| Low | 9 |
+
+
+# Low Issues
+
+## L-1: Centralization Risk
+
+Contracts have owners with privileged rights to perform admin tasks and need to be trusted to not perform malicious updates or drain funds.
+
+80 Found Instances
+
+
+- Found in src/modules/AccessControlModuleStandalone.sol [Line: 13](../../../../../home/ryan/Pictures/dev/Rules/src/modules/AccessControlModuleStandalone.sol#L13)
+
+ ```solidity
+ abstract contract AccessControlModuleStandalone is AccessControlEnumerable {
+ ```
+
+- Found in src/registry/IdentityRegistryWhitelist.sol [Line: 34](../../../../../home/ryan/Pictures/dev/Rules/src/registry/IdentityRegistryWhitelist.sol#L34)
+
+ ```solidity
+ function _authorizeIdentityRegistrar() internal view virtual override onlyRole(IDENTITY_REGISTRAR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L62)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 67](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L67)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 77](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L77)
+
+ ```solidity
+ onlyRole(COMPLIANCE_MANAGER_ROLE)
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 50](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L50)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L55)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 22](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L22)
+
+ ```solidity
+ Ownable2Step,
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 49](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L49)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 54](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L54)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 21](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L21)
+
+ ```solidity
+ Ownable2Step,
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L60)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L65)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 70](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L70)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address) internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L60)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L65)
+
+ ```solidity
+ function _authorizeSetMintAllowance() internal view virtual override onlyRole(ALLOWANCE_OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 75](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L75)
+
+ ```solidity
+ onlyRole(COMPLIANCE_MANAGER_ROLE)
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 19](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L19)
+
+ ```solidity
+ contract RuleMintAllowanceOwnable2Step is RuleMintAllowanceBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L57)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeSetMintAllowance() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 67](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L67)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address) internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L55)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L60)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L15)
+
+ ```solidity
+ contract RuleBlacklistOwnable2Step is RuleBlacklistBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 54](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L54)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 59](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L59)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoR.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoR.sol#L65)
+
+ ```solidity
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol#L15)
+
+ ```solidity
+ contract RuleChainlinkPoROwnable2Step is RuleChainlinkPoRBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 73](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L73)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 78](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L78)
+
+ ```solidity
+ function _authorizeWhitelistAdd() internal view virtual override onlyRole(WHITELIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 83](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L83)
+
+ ```solidity
+ function _authorizeWhitelistRemove() internal view virtual override onlyRole(WHITELIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 88](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L88)
+
+ ```solidity
+ function _authorizeFrozenlistAdd() internal view virtual override onlyRole(FROZENLIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 93](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L93)
+
+ ```solidity
+ function _authorizeFrozenlistRemove() internal view virtual override onlyRole(FROZENLIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L15)
+
+ ```solidity
+ contract RuleERC2980Ownable2Step is RuleERC2980Base, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L56)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L61)
+
+ ```solidity
+ function _authorizeWhitelistAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 66](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L66)
+
+ ```solidity
+ function _authorizeWhitelistRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 71](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L71)
+
+ ```solidity
+ function _authorizeFrozenlistAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 76](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L76)
+
+ ```solidity
+ function _authorizeFrozenlistRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistry.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistry.sol#L63)
+
+ ```solidity
+ function _authorizeIdentityRegistryManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol [Line: 14](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L14)
+
+ ```solidity
+ contract RuleIdentityRegistryOwnable2Step is RuleIdentityRegistryBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeIdentityRegistryManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalance.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalance.sol#L58)
+
+ ```solidity
+ function _authorizeMaxBalanceManager() internal view virtual override onlyRole(MAX_BALANCE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol [Line: 16](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol#L16)
+
+ ```solidity
+ contract RuleMaxBalanceOwnable2Step is RuleMaxBalanceBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol [Line: 59](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol#L59)
+
+ ```solidity
+ function _authorizeMaxBalanceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupply.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupply.sol#L55)
+
+ ```solidity
+ function _authorizeMaxTotalSupplyManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol [Line: 14](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol#L14)
+
+ ```solidity
+ contract RuleMaxTotalSupplyOwnable2Step is RuleMaxTotalSupplyBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeMaxTotalSupplyManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L56)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L61)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L15)
+
+ ```solidity
+ contract RuleReceiverWhitelistOwnable2Step is RuleReceiverWhitelistBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsList.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsList.sol#L58)
+
+ ```solidity
+ function _authorizeSanctionListManager() internal view virtual override onlyRole(SANCTIONLIST_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol [Line: 17](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol#L17)
+
+ ```solidity
+ contract RuleSanctionsListOwnable2Step is RuleSanctionsListBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol#L60)
+
+ ```solidity
+ function _authorizeSanctionListManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L56)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L61)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L15)
+
+ ```solidity
+ contract RuleSpenderWhitelistOwnable2Step is RuleSpenderWhitelistBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L65)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 70](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L70)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 75](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L75)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 80](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L80)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 15](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L15)
+
+ ```solidity
+ contract RuleWhitelistOwnable2Step is RuleWhitelistBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L58)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 68](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L68)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 73](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L73)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 98](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L98)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 103](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L103)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 109](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L109)
+
+ ```solidity
+ function _onlyRulesManager() internal view virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 114](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L114)
+
+ ```solidity
+ function _onlyRulesLimitManager() internal view virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 16](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L16)
+
+ ```solidity
+ contract RuleWhitelistWrapperOwnable2Step is RuleWhitelistWrapperBase, Ownable2Step, Ownable2StepERC165Module {
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L58)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 69](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L69)
+
+ ```solidity
+ function _onlyRulesManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 74](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L74)
+
+ ```solidity
+ function _onlyRulesLimitManager() internal view virtual override onlyOwner {}
+ ```
+
+
+
+
+
+## L-2: Unspecific Solidity Pragma
+
+Consider using a specific version of Solidity in your contracts instead of a wide version. For example, instead of `pragma solidity ^0.8.0;`, use `pragma solidity 0.8.0;`
+
+87 Found Instances
+
+
+- Found in src/modules/AccessControlModuleStandalone.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/modules/AccessControlModuleStandalone.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/MetaTxModuleStandalone.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/modules/MetaTxModuleStandalone.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/Ownable2StepERC165Module.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/modules/Ownable2StepERC165Module.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/VersionModule.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/modules/VersionModule.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/IdentityRegistryWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/IdentityRegistryWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/abstract/IdentityRegistryWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/abstract/IdentityRegistryWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/interfaces/IIdentityRegistryERC3643.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/interfaces/IIdentityRegistryERC3643.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/AggregatorV3Interface.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/AggregatorV3Interface.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IAddressList.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IAddressList.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IBalanceOf.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IBalanceOf.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IDecimals.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IDecimals.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IERC2980.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IERC2980.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IERC7943NonFungibleCompliance.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IERC7943NonFungibleCompliance.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IIdentityRegistry.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IIdentityRegistry.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ISanctionsList.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ISanctionsList.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ITotalSupply.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ITotalSupply.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ITransferContext.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ITransferContext.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleBlacklistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleBlacklistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleERC2980Base.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleERC2980Base.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleMaxBalanceBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleMaxBalanceBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSanctionsListBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSanctionsListBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/BalanceCapManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/BalanceCapManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleNFTAdapter.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleNFTAdapter.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleTransferValidation.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleTransferValidation.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleWhitelistShared.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleWhitelistShared.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/TokenSupplyReader.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/TokenSupplyReader.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/TotalSupplyCapManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/TotalSupplyCapManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoR.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoR.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistry.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistry.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalance.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalance.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupply.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupply.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsList.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsList.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+
+
+
+
+## L-3: Address State Variable Set Without Checks
+
+Check for `address(0)` when assigning values to address state variables.
+
+3 Found Instances
+
+
+- Found in src/rules/validation/abstract/base/RuleSanctionsListBase.sol [Line: 125](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSanctionsListBase.sol#L125)
+
+ ```solidity
+ sanctionsList = sanctionContractOracle_;
+ ```
+
+- Found in src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol [Line: 153](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L153)
+
+ ```solidity
+ reservesFeed = newReservesFeed;
+ ```
+
+- Found in src/rules/validation/abstract/core/TotalSupplyCapManager.sol [Line: 96](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/TotalSupplyCapManager.sol#L96)
+
+ ```solidity
+ tokenContract = ITotalSupply(newTokenContract);
+ ```
+
+
+
+
+
+## L-4: Literal Instead of Constant
+
+Define and use `constant` variables instead of using literals. If the same constant literal value is used multiple times, create a constant state variable and reference it throughout the contract.
+
+2 Found Instances
+
+
+- Found in src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol [Line: 267](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L267)
+
+ ```solidity
+ uint256 factor = 10 ** uint256(to - from);
+ ```
+
+- Found in src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol [Line: 274](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L274)
+
+ ```solidity
+ return answer / (10 ** uint256(from - to));
+ ```
+
+
+
+
+
+## L-5: PUSH0 Opcode
+
+Solc compiler version 0.8.20 switches the default target EVM version to Shanghai, which means that the generated bytecode will include PUSH0 opcodes. Be sure to select the appropriate EVM version in case you intend to deploy on a chain other than mainnet like L2 chains that may not support PUSH0, otherwise deployment of your contracts will fail.
+
+89 Found Instances
+
+
+- Found in src/modules/AccessControlModuleStandalone.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/modules/AccessControlModuleStandalone.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/MetaTxModuleStandalone.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/modules/MetaTxModuleStandalone.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/Ownable2StepERC165Module.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/modules/Ownable2StepERC165Module.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/modules/VersionModule.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/modules/VersionModule.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/IdentityRegistryWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/IdentityRegistryWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/abstract/IdentityRegistryWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/abstract/IdentityRegistryWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/registry/interfaces/IIdentityRegistryERC3643.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/registry/interfaces/IIdentityRegistryERC3643.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/AggregatorV3Interface.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/AggregatorV3Interface.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IAddressList.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IAddressList.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IBalanceOf.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IBalanceOf.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IDecimals.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IDecimals.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IERC2980.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IERC2980.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IERC7943NonFungibleCompliance.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IERC7943NonFungibleCompliance.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/IIdentityRegistry.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/IIdentityRegistry.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ISanctionsList.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ISanctionsList.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ITotalSupply.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ITotalSupply.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/ITransferContext.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/ITransferContext.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/interfaces/library/AddressListInterfaceId.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/interfaces/library/AddressListInterfaceId.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleBlacklistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleBlacklistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleERC2980Base.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleERC2980Base.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleMaxBalanceBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleMaxBalanceBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSanctionsListBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSanctionsListBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleWhitelistBase.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleWhitelistBase.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/BalanceCapManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/BalanceCapManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleNFTAdapter.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleNFTAdapter.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleTransferValidation.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleTransferValidation.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/RuleWhitelistShared.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleWhitelistShared.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/TokenSupplyReader.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/TokenSupplyReader.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/core/TotalSupplyCapManager.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/TotalSupplyCapManager.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoR.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoR.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistry.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistry.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalance.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalance.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupply.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupply.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsList.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsList.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 2](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L2)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 3](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L3)
+
+ ```solidity
+ pragma solidity ^0.8.20;
+ ```
+
+
+
+
+
+## L-6: Modifier Invoked Only Once
+
+Consider removing the modifier or inlining the logic into the calling function.
+
+1 Found Instances
+
+
+- Found in src/rules/validation/abstract/core/RuleWhitelistShared.sol [Line: 51](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/core/RuleWhitelistShared.sol#L51)
+
+ ```solidity
+ modifier onlyCheckSpenderManager() {
+ ```
+
+
+
+
+
+## L-7: Empty Block
+
+Consider removing empty blocks.
+
+70 Found Instances
+
+
+- Found in src/registry/IdentityRegistryWhitelist.sol [Line: 34](../../../../../home/ryan/Pictures/dev/Rules/src/registry/IdentityRegistryWhitelist.sol#L34)
+
+ ```solidity
+ function _authorizeIdentityRegistrar() internal view virtual override onlyRole(IDENTITY_REGISTRAR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L62)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 67](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L67)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLight.sol [Line: 72](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLight.sol#L72)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address)
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 50](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L50)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiToken.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiToken.sol#L55)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyRole(OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 49](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L49)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol [Line: 54](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol#L54)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L60)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L65)
+
+ ```solidity
+ function _authorizeTransferApproval() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol [Line: 70](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol#L70)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address) internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L60)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyRole(COMPLIANCE_MANAGER_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L65)
+
+ ```solidity
+ function _authorizeSetMintAllowance() internal view virtual override onlyRole(ALLOWANCE_OPERATOR_ROLE) {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowance.sol [Line: 70](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowance.sol#L70)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address)
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L57)
+
+ ```solidity
+ function _onlyComplianceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeSetMintAllowance() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/RuleMintAllowanceOwnable2Step.sol [Line: 67](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/RuleMintAllowanceOwnable2Step.sol#L67)
+
+ ```solidity
+ function _authorizeComplianceBindingChange(address) internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L63)
+
+ ```solidity
+ function created(address, uint256) external virtual override onlyBoundToken {}
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 68](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L68)
+
+ ```solidity
+ function destroyed(address, uint256) external virtual override onlyBoundToken {}
+ ```
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 258](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L258)
+
+ ```solidity
+ function _transferred(address, address, uint256) internal virtual {
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol [Line: 49](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol#L49)
+
+ ```solidity
+ function transferred(address, address, uint256) public view override(IERC3643IComplianceContract) {}
+ ```
+
+- Found in src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol [Line: 120](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/base/RuleSpenderWhitelistBase.sol#L120)
+
+ ```solidity
+ function _transferred(address, address, uint256) internal view virtual override {
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L55)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklist.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklist.sol#L60)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 54](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L54)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol [Line: 59](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleBlacklistOwnable2Step.sol#L59)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoR.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoR.sol#L65)
+
+ ```solidity
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 73](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L73)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 78](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L78)
+
+ ```solidity
+ function _authorizeWhitelistAdd() internal view virtual override onlyRole(WHITELIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 83](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L83)
+
+ ```solidity
+ function _authorizeWhitelistRemove() internal view virtual override onlyRole(WHITELIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 88](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L88)
+
+ ```solidity
+ function _authorizeFrozenlistAdd() internal view virtual override onlyRole(FROZENLIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980.sol [Line: 93](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980.sol#L93)
+
+ ```solidity
+ function _authorizeFrozenlistRemove() internal view virtual override onlyRole(FROZENLIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L56)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L61)
+
+ ```solidity
+ function _authorizeWhitelistAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 66](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L66)
+
+ ```solidity
+ function _authorizeWhitelistRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 71](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L71)
+
+ ```solidity
+ function _authorizeFrozenlistAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleERC2980Ownable2Step.sol [Line: 76](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleERC2980Ownable2Step.sol#L76)
+
+ ```solidity
+ function _authorizeFrozenlistRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistry.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistry.sol#L63)
+
+ ```solidity
+ function _authorizeIdentityRegistryManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeIdentityRegistryManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalance.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalance.sol#L58)
+
+ ```solidity
+ function _authorizeMaxBalanceManager() internal view virtual override onlyRole(MAX_BALANCE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol [Line: 59](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol#L59)
+
+ ```solidity
+ function _authorizeMaxBalanceManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupply.sol [Line: 55](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupply.sol#L55)
+
+ ```solidity
+ function _authorizeMaxTotalSupplyManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeMaxTotalSupplyManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L56)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelist.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelist.sol#L61)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsList.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsList.sol#L58)
+
+ ```solidity
+ function _authorizeSanctionListManager() internal view virtual override onlyRole(SANCTIONLIST_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol [Line: 60](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol#L60)
+
+ ```solidity
+ function _authorizeSanctionListManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L56)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelist.sol [Line: 61](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelist.sol#L61)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 57](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L57)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol [Line: 62](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol#L62)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 65](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L65)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 70](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L70)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 75](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L75)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelist.sol [Line: 80](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelist.sol#L80)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L58)
+
+ ```solidity
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 68](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L68)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol [Line: 73](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol#L73)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 98](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L98)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 103](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L103)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 109](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L109)
+
+ ```solidity
+ function _onlyRulesManager() internal view virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapper.sol [Line: 114](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapper.sol#L114)
+
+ ```solidity
+ function _onlyRulesLimitManager() internal view virtual override onlyRole(RULES_MANAGEMENT_ROLE) {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 58](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L58)
+
+ ```solidity
+ function _authorizeCheckSpenderManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 63](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L63)
+
+ ```solidity
+ function _authorizeMintBurnManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 69](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L69)
+
+ ```solidity
+ function _onlyRulesManager() internal view virtual override onlyOwner {}
+ ```
+
+- Found in src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol [Line: 74](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol#L74)
+
+ ```solidity
+ function _onlyRulesLimitManager() internal view virtual override onlyOwner {}
+ ```
+
+
+
+
+
+## L-8: Costly operations inside loop
+
+Invoking `SSTORE` operations in loops may waste gas. Use a local variable to hold the loop computation result.
+
+3 Found Instances
+
+
+- Found in src/rules/operation/abstract/RuleMintAllowanceBase.sol [Line: 125](../../../../../home/ryan/Pictures/dev/Rules/src/rules/operation/abstract/RuleMintAllowanceBase.sol#L125)
+
+ ```solidity
+ for (uint256 i = 0; i < minters.length; ++i) {
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol [Line: 56](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol#L56)
+
+ ```solidity
+ for (uint256 i = 0; i < addressesToAdd.length; ++i) {
+ ```
+
+- Found in src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol [Line: 79](../../../../../home/ryan/Pictures/dev/Rules/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol#L79)
+
+ ```solidity
+ for (uint256 i = 0; i < addressesToRemove.length; ++i) {
+ ```
+
+
+
+
+
+## L-9: Unchecked Return
+
+Function returns a value but it is ignored. Consider checking the return value.
+
+1 Found Instances
+
+
+- Found in src/modules/AccessControlModuleStandalone.sol [Line: 35](../../../../../home/ryan/Pictures/dev/Rules/src/modules/AccessControlModuleStandalone.sol#L35)
+
+ ```solidity
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
+ ```
+
+
+
+
+
diff --git a/doc/security/audits/tools/v0.5.0/slither-report-feedback.md b/doc/security/audits/tools/v0.5.0/slither-report-feedback.md
new file mode 100644
index 00000000..bd1b43ef
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/slither-report-feedback.md
@@ -0,0 +1,81 @@
+# Slither `v0.5.0` — triage
+
+```bash
+slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
+ > doc/security/audits/tools/v0.5.0/slither-report.md
+```
+
+Tool: **Slither 0.11.5** · Compiler: solc `0.8.36` · Run date: **2026-08-13**
+Scope: production contracts only. Mocks excluded via the `mocks` filter, vendored dependencies via `lib`.
+208 contracts, 101 detectors, **44 results**.
+
+This run supersedes the earlier `v0.5.0` runs and was made after the cap-manager split
+(`TotalSupplyCapManager`, `BalanceCapManager`).
+
+**Executive triage: nothing to fix.** No finding is exploitable. The two High-severity results are false
+positives on a permissioned path. One finding is new since the previous run, and it is the same false-positive
+class as three already dismissed.
+
+### Scope check
+
+Both scope assertions pass:
+
+```
+grep -c 'lib/\|node_modules/' slither-report.md → 0
+grep -c 'test/\|src/mocks/' slither-report.md → 0
+```
+
+The filter list matters on this repository. A first run of `v0.5.0` used a generic `submodules` filter; because
+this is a Foundry project the vendored dependencies live in `lib/`, so they entered scope and the run reported
+**170 results, 351 of them citing `lib/openzeppelin-contracts/`**. If a future run's count jumps by an order of
+magnitude, check the filter before reading anything into it.
+
+## Per-detector triage
+
+| Detector | Severity | Instances | Disposition | Reason (verified against source) |
+|---|---|---|---|---|
+| `arbitrary-send-erc20` | **High** | 2 | **False positive** | `RuleConditionalTransferLightMultiTokenBase.approveAndTransferIfAllowed` passes a caller-supplied `from` to `safeTransferFrom`, but the call is reachable only through `onlyTransferApprover`, requires a previously recorded approval for the exact `(token, from, to, value)` tuple, and still needs the holder's own ERC-20 allowance. The holder's approval is the authorisation; the rule cannot move tokens the holder has not already approved. |
+| `uninitialized-local` | Medium | 2 | **False positive** | `ChainlinkPoRFeedManager`'s `newFeedDecimals` and `currentFeedDecimals`. Both are declared before a `try` and assigned inside it — Solidity requires this, since a `try` cannot declare a variable that outlives its own scope. The matching `catch` **reverts** or **returns**, so control never reaches a read with the variable unset. |
+| `unused-return` | Medium | 9 | **False positive** | Six are batch helpers in `RuleAddressSetInternal` and `RuleERC2980Internal`, e.g. `return _whitelist.removeBatch(addressesToRemove);` — the `(removed, skipped)` tuple is **returned straight to the caller**, so nothing is discarded; Slither flags the forwarding pattern itself. The other three are deliberate probes: `TokenSupplyReader`'s `try …totalSupply()`, `ChainlinkPoRFeedManager`'s partial destructuring of `latestRoundData()`, and the one new this run — see below. |
+| `calls-loop` | Low | 16 | **By design** | `RuleWhitelistWrapperBase`'s child-rule scan and the batch list operations. The gas cost is documented with measurements in [`RuleWhitelistWrapper.md`](../../../../technical/contracts/RuleWhitelistWrapper.md#gas-cost-of-the-child-rule-scan), including the guidance to keep the child list at or below 10. |
+| `timestamp` | Low | 1 | **By design** | `ChainlinkPoRFeedManager._maxBackedSupply` compares `block.timestamp` against the feed's `updatedAt`. That comparison **is** the Proof-of-Reserve staleness feature. It is guarded against underflow, and `maxStalenessSeconds` is configured from the feed heartbeat — hours — so validator drift of a few seconds cannot flip the outcome. |
+| `assembly` | Informational | 2 | **By design** | `RuleConditionalTransferLightApprovalBase._transferHash` and `IdentityRegistryWhitelistBase._walletKey`, both written in assembly to satisfy the project's `asm-keccak256` lint convention. The second is pinned by a test asserting it is byte-identical to `keccak256(abi.encode(wallet))`. |
+| `dead-code` | Informational | 2 | **False positive** | `RuleAddressSetInternal._requireNotZeroAddress` and `RuleERC2980Internal._requireNotZeroAddress` are reported as never used. Both **are** used — passed as internal function pointers to `AddressSetBatchLib.addBatch`. Slither does not resolve internal function pointers. The zero-address rejection is exercised by the test suite. |
+| `naming-convention` | Informational | 6 | **By design** | Four are `_userAddress` / `_identity` in `IdentityRegistryWhitelistBase`, reproducing the ERC-3643 `IIdentityRegistry` parameter names **verbatim** so the interface reads identically to the standard. Two are pre-existing in `RuleERC2980Base`. |
+| `unused-state` | Informational | 4 | **Cosmetic** | The four `TRANSFERRED_SELECTOR_*` constants in `RuleNFTAdapter`. Each occurs exactly once in the repository — its own declaration — so Slither is right that they are unreferenced. Impact is nil: they are `internal constant`, so they occupy no storage slot and, being unused, are not emitted into the deployed bytecode. Either delete them or add a test asserting each equals the corresponding overload's selector, which would make them load-bearing and pin the ERC-7943 signatures. |
+
+## The one new finding
+
+`unused-return` rose from 8 to 9. The new instance is in `RuleMaxBalanceBase._setBalanceToken`:
+
+```solidity
+try IBalanceOf(newBalanceToken).balanceOf(address(this)) returns (uint256) {
+ // callable
+} catch {
+ revert RuleMaxBalance_TokenBalanceUnavailable(newBalanceToken);
+}
+```
+
+**False positive, and the same class as three findings already dismissed.** The call is a *probe*: its only
+purpose is to establish that `balanceOf` does not revert, turning what would otherwise be a silent read-path
+failure — every transfer blocked with code `83` — into an immediate, named configuration error. Discarding the
+value is the point; there is no balance to act on at configuration time. This mirrors
+`TokenSupplyReader._probeTotalSupplyCallable`, dismissed on identical grounds.
+
+## Delta from the previous run
+
+| | Previous (pre cap-manager split) | This run |
+|---|---|---|
+| Contracts | 206 | **208** |
+| Results | 44 | **44** |
+| Severity | 2 High · 11 Med · 17 Low · 14 Info | identical |
+
+**Every detector reports the same instance count, one for one.** The split of `RuleMaxTotalSupplyBase` and
+`RuleMaxBalanceBase` into `TotalSupplyCapManager` and `BalanceCapManager` moved code between files without
+changing it, so `unused-return`, `uninitialized-local`, `timestamp` and the rest report exactly what they did
+before, now attributed to the new contracts where relevant. Two contracts were added and produced **no** new
+finding.
+
+That is the expected signature of a pure code move, and it is the check worth doing: a refactor that claimed to
+be behaviour-preserving but shifted a detector count would deserve a second look. Storage layout and ABI were
+separately verified identical for all four affected deployable contracts.
diff --git a/doc/security/audits/tools/v0.5.0/slither-report.md b/doc/security/audits/tools/v0.5.0/slither-report.md
new file mode 100644
index 00000000..35bd58d8
--- /dev/null
+++ b/doc/security/audits/tools/v0.5.0/slither-report.md
@@ -0,0 +1,419 @@
+# Slither Report — `v0.5.0`
+
+```bash
+slither . --checklist --filter-paths "node_modules,lib,test,forge-std,mocks" \
+ > doc/security/audits/tools/v0.5.0/slither-report.md
+```
+
+Tool: **Slither 0.11.5** · Scope: production contracts only (**mocks excluded**, dependencies excluded via the
+`lib` filter) · 208 contracts, 101 detectors, 44 results
+Compiler: solc `0.8.36` · Run date: 2026-08-13, after the cap-manager split (supersedes the earlier `v0.5.0` runs)
+
+**Result: 2 High · 11 Medium · 17 Low · 14 Informational. Nothing to fix** — every finding is a false positive,
+a by-design pattern, or cosmetic. Verified line-by-line in the
+[feedback file](./slither-report-feedback.md).
+
+| Detector | Severity | Instances | Δ vs previous run | Assessment |
+|---|---|---|---|---|
+| `arbitrary-send-erc20` | **High** | 2 | — | **False positive** — `approveAndTransferIfAllowed` is reachable only via `onlyTransferApprover`, needs a recorded approval for the exact tuple, and still needs the holder's own ERC-20 allowance |
+| `uninitialized-local` | Medium | 2 | — | **False positive** — declared before a `try` and assigned inside it; the matching `catch` reverts or returns |
+| `unused-return` | Medium | 9 | — | **False positive** — six batch helpers forward the library's `(added, skipped)` tuple to the caller; three are deliberate probes whose only purpose is to detect a revert |
+| `calls-loop` | Low | 16 | — | By design — the wrapper child-rule scan and batch list operations, with measured gas guidance |
+| `timestamp` | Low | 1 | — | By design — the Proof-of-Reserve staleness comparison is the feature |
+| `assembly` | Informational | 2 | — | By design — `_transferHash` and `_walletKey`, both pinned by tests |
+| `dead-code` | Informational | 2 | — | **False positive** — the two `_requireNotZeroAddress` guards are passed as internal function pointers, which Slither does not resolve |
+| `naming-convention` | Informational | 6 | — | By design — ERC-3643 parameter names reproduced verbatim |
+| `unused-state` | Informational | 4 | — | Cosmetic — the four `TRANSFERRED_SELECTOR_*` constants are genuinely unreferenced; `internal constant`, so no storage and not emitted into bytecode |
+
+Overview: [`AUDIT_OVERVIEW.md`](../../AUDIT_OVERVIEW.md)
+
+---
+
+**THIS CHECKLIST IS NOT COMPLETE**. Use `--show-ignored-findings` to show all the results.
+Summary
+ - [arbitrary-send-erc20](#arbitrary-send-erc20) (2 results) (High)
+ - [uninitialized-local](#uninitialized-local) (2 results) (Medium)
+ - [unused-return](#unused-return) (9 results) (Medium)
+ - [calls-loop](#calls-loop) (16 results) (Low)
+ - [timestamp](#timestamp) (1 results) (Low)
+ - [assembly](#assembly) (2 results) (Informational)
+ - [dead-code](#dead-code) (2 results) (Informational)
+ - [naming-convention](#naming-convention) (6 results) (Informational)
+ - [unused-state](#unused-state) (4 results) (Informational)
+## arbitrary-send-erc20
+Impact: High
+Confidence: High
+ - [ ] ID-0
+[RuleConditionalTransferLightBase.approveAndTransferIfAllowed(address,address,uint256)](src/rules/operation/abstract/RuleConditionalTransferLightBase.sol#L113-L129) uses arbitrary from in transferFrom: [IERC20(token).safeTransferFrom(from,to,value)](src/rules/operation/abstract/RuleConditionalTransferLightBase.sol#L127)
+
+src/rules/operation/abstract/RuleConditionalTransferLightBase.sol#L113-L129
+
+
+ - [ ] ID-1
+[RuleConditionalTransferLightMultiTokenBase.approveAndTransferIfAllowed(address,address,address,uint256)](src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L131-L148) uses arbitrary from in transferFrom: [IERC20(token).safeTransferFrom(from,to,value)](src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L146)
+
+src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L131-L148
+
+
+## uninitialized-local
+Impact: Medium
+Confidence: Medium
+ - [ ] ID-2
+[ChainlinkPoRFeedManager._maxBackedSupply().currentFeedDecimals](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L215) is a local variable never initialized
+
+src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L215
+
+
+ - [ ] ID-3
+[ChainlinkPoRFeedManager._setReservesFeed(AggregatorV3Interface).newFeedDecimals](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L146) is a local variable never initialized
+
+src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L146
+
+
+## unused-return
+Impact: Medium
+Confidence: Medium
+ - [ ] ID-4
+[RuleERC2980Internal._removeFrozenlistAddresses(address[])](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L110-L116) ignores return value by [_frozenlist.removeBatch(addressesToRemove)](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L115)
+
+src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L110-L116
+
+
+ - [ ] ID-5
+[RuleAddressSetInternal._removeAddresses(address[])](src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L77-L83) ignores return value by [_listedAddresses.removeBatch(addressesToRemove)](src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L82)
+
+src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L77-L83
+
+
+ - [ ] ID-6
+[RuleERC2980Internal._removeWhitelistAddresses(address[])](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L61-L67) ignores return value by [_whitelist.removeBatch(addressesToRemove)](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L66)
+
+src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L61-L67
+
+
+ - [ ] ID-7
+[RuleERC2980Internal._addWhitelistAddresses(address[])](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L47-L53) ignores return value by [_whitelist.addBatch(addressesToAdd,_requireNotZeroAddress)](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L52)
+
+src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L47-L53
+
+
+ - [ ] ID-8
+[BalanceCapManager._setBalanceToken(address)](src/rules/validation/abstract/core/BalanceCapManager.sol#L191-L204) ignores return value by [IBalanceOf(newBalanceToken).balanceOf(address(this))](src/rules/validation/abstract/core/BalanceCapManager.sol#L196-L201)
+
+src/rules/validation/abstract/core/BalanceCapManager.sol#L191-L204
+
+
+ - [ ] ID-9
+[RuleERC2980Internal._addFrozenlistAddresses(address[])](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L96-L102) ignores return value by [_frozenlist.addBatch(addressesToAdd,_requireNotZeroAddress)](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L101)
+
+src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L96-L102
+
+
+ - [ ] ID-10
+[TokenSupplyReader._probeTotalSupplyCallable(address)](src/rules/validation/abstract/core/TokenSupplyReader.sol#L82-L88) ignores return value by [ITotalSupply(candidate).totalSupply()](src/rules/validation/abstract/core/TokenSupplyReader.sol#L83-L87)
+
+src/rules/validation/abstract/core/TokenSupplyReader.sol#L82-L88
+
+
+ - [ ] ID-11
+[RuleAddressSetInternal._addAddresses(address[])](src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L44-L50) ignores return value by [_listedAddresses.addBatch(addressesToAdd,_requireNotZeroAddress)](src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L49)
+
+src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L44-L50
+
+
+ - [ ] ID-12
+[ChainlinkPoRFeedManager._maxBackedSupply()](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L211-L242) ignores return value by [(answer,updatedAt) = feed.latestRoundData()](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L226-L241)
+
+src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L211-L242
+
+
+## calls-loop
+Impact: Low
+Confidence: Medium
+ - [ ] ID-13
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.canTransferFrom(address,address,address,uint256,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-14
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleTransferValidation.canTransfer(address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-15
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleWhitelistShared.transferred(address,address,uint256)
+ RuleWhitelistWrapperBase._transferred(address,address,uint256)
+ RuleWhitelistShared._transferred(address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-16
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleTransferValidation.canTransferFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-17
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.transferred(ITransferContext.FungibleTransferContext)
+ RuleWhitelistShared._transferredFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-18
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.transferred(address,address,uint256,uint256)
+ RuleWhitelistWrapperBase._transferred(address,address,uint256)
+ RuleWhitelistShared._transferred(address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-19
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.detectTransferRestriction(address,address,uint256,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-20
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleWhitelistWrapperHarnessInternal.exposedTransferredSpenderInternal(address,address,address,uint256)
+ RuleWhitelistWrapperBase._transferred(address,address,address,uint256)
+ RuleWhitelistShared._transferredFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-21
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleWhitelistWrapperBase.isVerified(address)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-22
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.transferred(ITransferContext.MultiTokenTransferContext)
+ RuleWhitelistShared._transferredFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-23
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleTransferValidation.detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-24
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.transferred(address,address,address,uint256,uint256)
+ RuleWhitelistShared._transferredFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-25
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleTransferValidation.detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-26
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleWhitelistShared.transferred(address,address,address,uint256)
+ RuleWhitelistShared._transferredFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-27
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.detectTransferRestrictionFrom(address,address,address,uint256,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestrictionFrom(address,address,address,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+ - [ ] ID-28
+[RuleWhitelistWrapperBase._detectTransferRestrictionForTargets(address[])](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251) has external calls inside a loop: [isListed = IAddressList(rule(i)).areAddressesListed(targetAddress)](src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L237)
+ Calls stack containing the loop:
+ RuleNFTAdapter.canTransfer(address,address,uint256,uint256)
+ RuleWhitelistWrapperBase._detectTransferRestriction(address,address,uint256)
+ RuleWhitelistWrapperBase._isListedInAnyChild(address)
+
+src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol#L221-L251
+
+
+## timestamp
+Impact: Low
+Confidence: Medium
+ - [ ] ID-29
+[ChainlinkPoRFeedManager._maxBackedSupply()](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L211-L242) uses timestamp for comparisons
+ Dangerous comparisons:
+ - [staleness != 0 && block.timestamp > updatedAt && block.timestamp - updatedAt > staleness](src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L232)
+
+src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol#L211-L242
+
+
+## assembly
+Impact: Informational
+Confidence: High
+ - [ ] ID-30
+[RuleConditionalTransferLightApprovalBase._transferHash(address,address,uint256)](src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol#L177-L188) uses assembly
+ - [INLINE ASM](src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol#L181-L187)
+
+src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol#L177-L188
+
+
+ - [ ] ID-31
+[RuleConditionalTransferLightMultiTokenBase._transferHash(address,address,address,uint256)](src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L464-L478) uses assembly
+ - [INLINE ASM](src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L470-L477)
+
+src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol#L464-L478
+
+
+## dead-code
+Impact: Informational
+Confidence: Medium
+ - [ ] ID-32
+[RuleERC2980Internal._requireNotZeroAddress(address)](src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L142-L144) is never used and should be removed
+
+src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol#L142-L144
+
+
+ - [ ] ID-33
+[RuleAddressSetInternal._requireNotZeroAddress(address)](src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L64-L66) is never used and should be removed
+
+src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol#L64-L66
+
+
+## naming-convention
+Impact: Informational
+Confidence: High
+ - [ ] ID-34
+Parameter [RuleERC2980Base.frozenlist(address)._operator](src/rules/validation/abstract/base/RuleERC2980Base.sol#L374) is not in mixedCase
+
+src/rules/validation/abstract/base/RuleERC2980Base.sol#L374
+
+
+ - [ ] ID-35
+Parameter [IdentityRegistryWhitelistBase.isVerified(address)._userAddress](src/registry/abstract/IdentityRegistryWhitelistBase.sol#L117) is not in mixedCase
+
+src/registry/abstract/IdentityRegistryWhitelistBase.sol#L117
+
+
+ - [ ] ID-36
+Parameter [IdentityRegistryWhitelistBase.deleteIdentity(address)._userAddress](src/registry/abstract/IdentityRegistryWhitelistBase.sol#L92) is not in mixedCase
+
+src/registry/abstract/IdentityRegistryWhitelistBase.sol#L92
+
+
+ - [ ] ID-37
+Parameter [RuleERC2980Base.whitelist(address)._operator](src/rules/validation/abstract/base/RuleERC2980Base.sol#L325) is not in mixedCase
+
+src/rules/validation/abstract/base/RuleERC2980Base.sol#L325
+
+
+ - [ ] ID-38
+Parameter [IdentityRegistryWhitelistBase.registerIdentity(address,address,uint16)._identity](src/registry/abstract/IdentityRegistryWhitelistBase.sol#L73) is not in mixedCase
+
+src/registry/abstract/IdentityRegistryWhitelistBase.sol#L73
+
+
+ - [ ] ID-39
+Parameter [IdentityRegistryWhitelistBase.registerIdentity(address,address,uint16)._userAddress](src/registry/abstract/IdentityRegistryWhitelistBase.sol#L72) is not in mixedCase
+
+src/registry/abstract/IdentityRegistryWhitelistBase.sol#L72
+
+
+## unused-state
+Impact: Informational
+Confidence: High
+ - [ ] ID-40
+[RuleNFTAdapter.TRANSFERRED_SELECTOR_RULE_ENGINE](src/rules/validation/abstract/core/RuleNFTAdapter.sol#L27) is never used in [RuleIdentityRegistryOwnable2Step](src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L14-L64)
+
+src/rules/validation/abstract/core/RuleNFTAdapter.sol#L27
+
+
+ - [ ] ID-41
+[RuleNFTAdapter.TRANSFERRED_SELECTOR_ERC7943](src/rules/validation/abstract/core/RuleNFTAdapter.sol#L31-L32) is never used in [RuleIdentityRegistryOwnable2Step](src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L14-L64)
+
+src/rules/validation/abstract/core/RuleNFTAdapter.sol#L31-L32
+
+
+ - [ ] ID-42
+[RuleNFTAdapter.TRANSFERRED_SELECTOR_ERC7943_FROM](src/rules/validation/abstract/core/RuleNFTAdapter.sol#L36-L37) is never used in [RuleIdentityRegistryOwnable2Step](src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L14-L64)
+
+src/rules/validation/abstract/core/RuleNFTAdapter.sol#L36-L37
+
+
+ - [ ] ID-43
+[RuleNFTAdapter.TRANSFERRED_SELECTOR_ERC3643](src/rules/validation/abstract/core/RuleNFTAdapter.sol#L23) is never used in [RuleIdentityRegistryOwnable2Step](src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol#L14-L64)
+
+src/rules/validation/abstract/core/RuleNFTAdapter.sol#L23
+
+
diff --git a/doc/specification/RulesSpecificationv0.4.0.pdf b/doc/specification/RulesSpecificationv0.4.0.pdf
new file mode 100644
index 00000000..13765639
Binary files /dev/null and b/doc/specification/RulesSpecificationv0.4.0.pdf differ
diff --git a/doc/specification/RulesSpecificationv0.3.0.pdf b/doc/specification/archive/RulesSpecificationv0.3.0.pdf
similarity index 100%
rename from doc/specification/RulesSpecificationv0.3.0.pdf
rename to doc/specification/archive/RulesSpecificationv0.3.0.pdf
diff --git a/doc/specification/cover_page.odg b/doc/specification/cover_page.odg
index 4645f13b..7f93e3c0 100644
Binary files a/doc/specification/cover_page.odg and b/doc/specification/cover_page.odg differ
diff --git a/doc/specification/cover_page.pdf b/doc/specification/cover_page.pdf
index 8dd7fe2a..e2a6ec62 100644
Binary files a/doc/specification/cover_page.pdf and b/doc/specification/cover_page.pdf differ
diff --git a/doc/surya/surya_graph/surya_graph_AddressListInterfaceId.sol.png b/doc/surya/surya_graph/surya_graph_AddressListInterfaceId.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_AddressListInterfaceId.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_AddressSetBatchLib.sol.png b/doc/surya/surya_graph/surya_graph_AddressSetBatchLib.sol.png
new file mode 100644
index 00000000..8a65bd51
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_AddressSetBatchLib.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_AggregatorV3Interface.sol.png b/doc/surya/surya_graph/surya_graph_AggregatorV3Interface.sol.png
new file mode 100644
index 00000000..79871d03
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_AggregatorV3Interface.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_AggregatorV3Mock.sol.png b/doc/surya/surya_graph/surya_graph_AggregatorV3Mock.sol.png
new file mode 100644
index 00000000..6edc2c39
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_AggregatorV3Mock.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_BalanceCapManager.sol.png b/doc/surya/surya_graph/surya_graph_BalanceCapManager.sol.png
new file mode 100644
index 00000000..e3c22d68
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_BalanceCapManager.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_BalanceOfMock.sol.png b/doc/surya/surya_graph/surya_graph_BalanceOfMock.sol.png
new file mode 100644
index 00000000..fddef2d8
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_BalanceOfMock.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_ChainlinkPoRFeedManager.sol.png b/doc/surya/surya_graph/surya_graph_ChainlinkPoRFeedManager.sol.png
new file mode 100644
index 00000000..aca8c7ad
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_ChainlinkPoRFeedManager.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_ERC3643TokenMock.sol.png b/doc/surya/surya_graph/surya_graph_ERC3643TokenMock.sol.png
new file mode 100644
index 00000000..3fc1df2c
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_ERC3643TokenMock.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IAddressListInterfaceIdHelper.sol.png b/doc/surya/surya_graph/surya_graph_IAddressListInterfaceIdHelper.sol.png
new file mode 100644
index 00000000..f1fba021
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IAddressListInterfaceIdHelper.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IBalanceOf.sol.png b/doc/surya/surya_graph/surya_graph_IBalanceOf.sol.png
new file mode 100644
index 00000000..df65c38d
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IBalanceOf.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IDecimals.sol.png b/doc/surya/surya_graph/surya_graph_IDecimals.sol.png
new file mode 100644
index 00000000..680b76c9
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IDecimals.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IERC3643ComplianceFull.sol.png b/doc/surya/surya_graph/surya_graph_IERC3643ComplianceFull.sol.png
index ca029c7f..0499ca44 100644
Binary files a/doc/surya/surya_graph/surya_graph_IERC3643ComplianceFull.sol.png and b/doc/surya/surya_graph/surya_graph_IERC3643ComplianceFull.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IERC7943NonFungibleCompliance.sol.png b/doc/surya/surya_graph/surya_graph_IERC7943NonFungibleCompliance.sol.png
index fb5cd4c8..8ff197bc 100644
Binary files a/doc/surya/surya_graph/surya_graph_IERC7943NonFungibleCompliance.sol.png and b/doc/surya/surya_graph/surya_graph_IERC7943NonFungibleCompliance.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IIdentityRegistryERC3643.sol.png b/doc/surya/surya_graph/surya_graph_IIdentityRegistryERC3643.sol.png
new file mode 100644
index 00000000..31fe6c51
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IIdentityRegistryERC3643.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelist.sol.png b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelist.sol.png
new file mode 100644
index 00000000..c932d6bf
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelist.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistBase.sol.png b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistBase.sol.png
new file mode 100644
index 00000000..47ca05d0
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistInvariantStorage.sol.png b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistInvariantStorage.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_IdentityRegistryWhitelistInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_OnchainIdMock.sol.png b/doc/surya/surya_graph/surya_graph_OnchainIdMock.sol.png
new file mode 100644
index 00000000..abd3c4e2
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_OnchainIdMock.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleAddressSet.sol.png b/doc/surya/surya_graph/surya_graph_RuleAddressSet.sol.png
index 92f6353b..1a13b439 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleAddressSet.sol.png and b/doc/surya/surya_graph/surya_graph_RuleAddressSet.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleAddressSetInternal.sol.png b/doc/surya/surya_graph/surya_graph_RuleAddressSetInternal.sol.png
index ba036a50..957b10eb 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleAddressSetInternal.sol.png and b/doc/surya/surya_graph/surya_graph_RuleAddressSetInternal.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleAddressSetRolesStorage.sol.png b/doc/surya/surya_graph/surya_graph_RuleAddressSetRolesStorage.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleAddressSetRolesStorage.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleBlacklistOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleBlacklistOwnable2Step.sol.png
index 36e969b4..fd47a4c9 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleBlacklistOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleBlacklistOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleChainlinkPoR.sol.png b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoR.sol.png
new file mode 100644
index 00000000..74ec5bba
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoR.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRBase.sol.png
new file mode 100644
index 00000000..c298df5c
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRInvariantStorage.sol.png b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRInvariantStorage.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoRInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleChainlinkPoROwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoROwnable2Step.sol.png
new file mode 100644
index 00000000..cfe5c82f
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleChainlinkPoROwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLight.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLight.sol.png
index 66c1899a..6319530e 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLight.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLight.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightApprovalBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightApprovalBase.sol.png
index 87a65655..95b99dac 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightApprovalBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightApprovalBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightBase.sol.png
index 851ca72e..04a9b436 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiToken.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiToken.sol.png
index 4ef47d69..98c2d188 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiToken.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiToken.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenBase.sol.png
index 7e79f6d2..0cae2e5a 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.png
index 836581be..9c84a20b 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightOwnable2Step.sol.png
index 8aac6851..6ded68fc 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleConditionalTransferLightOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleERC2980.sol.png b/doc/surya/surya_graph/surya_graph_RuleERC2980.sol.png
index a620cebe..f8a16eae 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleERC2980.sol.png and b/doc/surya/surya_graph/surya_graph_RuleERC2980.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleERC2980Base.sol.png b/doc/surya/surya_graph/surya_graph_RuleERC2980Base.sol.png
index 553421c6..4c4d6c82 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleERC2980Base.sol.png and b/doc/surya/surya_graph/surya_graph_RuleERC2980Base.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleERC2980Internal.sol.png b/doc/surya/surya_graph/surya_graph_RuleERC2980Internal.sol.png
index 39a39072..b4dce8b7 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleERC2980Internal.sol.png and b/doc/surya/surya_graph/surya_graph_RuleERC2980Internal.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleERC2980Ownable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleERC2980Ownable2Step.sol.png
index 69fec146..0bdccd74 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleERC2980Ownable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleERC2980Ownable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryBase.sol.png
index ff374919..746ac67d 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryOwnable2Step.sol.png
index c5964a61..e5104aac 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleIdentityRegistryOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxBalance.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxBalance.sol.png
new file mode 100644
index 00000000..61e909de
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleMaxBalance.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxBalanceBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceBase.sol.png
new file mode 100644
index 00000000..962f223d
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxBalanceInvariantStorage.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceInvariantStorage.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxBalanceOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceOwnable2Step.sol.png
new file mode 100644
index 00000000..450264f2
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleMaxBalanceOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyBase.sol.png
index fa6c8adc..9f4ebebd 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyOwnable2Step.sol.png
index 50a285b9..f3472cc9 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleMaxTotalSupplyOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMintAllowance.sol.png b/doc/surya/surya_graph/surya_graph_RuleMintAllowance.sol.png
index b0c87066..0e88e49a 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleMintAllowance.sol.png and b/doc/surya/surya_graph/surya_graph_RuleMintAllowance.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMintAllowanceBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleMintAllowanceBase.sol.png
index 90df11f6..8a2756f0 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleMintAllowanceBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleMintAllowanceBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleMintAllowanceOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleMintAllowanceOwnable2Step.sol.png
index 8ed397f6..2a2ffc65 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleMintAllowanceOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleMintAllowanceOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleNFTAdapter.sol.png b/doc/surya/surya_graph/surya_graph_RuleNFTAdapter.sol.png
index 3197aaa8..68f2165e 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleNFTAdapter.sol.png and b/doc/surya/surya_graph/surya_graph_RuleNFTAdapter.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelist.sol.png b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelist.sol.png
new file mode 100644
index 00000000..767a0676
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelist.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistBase.sol.png
new file mode 100644
index 00000000..268f51d6
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistHarnesses.sol.png b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistHarnesses.sol.png
new file mode 100644
index 00000000..65e43ac4
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistHarnesses.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistInvariantStorage.sol.png b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistInvariantStorage.sol.png
new file mode 100644
index 00000000..5b4fa6b1
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistOwnable2Step.sol.png
new file mode 100644
index 00000000..b59702d4
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_RuleReceiverWhitelistOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleSanctionsListBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleSanctionsListBase.sol.png
index aeea5f23..aba47021 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleSanctionsListBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleSanctionsListBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleSanctionsListOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleSanctionsListOwnable2Step.sol.png
index f3f52a43..9fa1c1fc 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleSanctionsListOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleSanctionsListOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelist.sol.png b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelist.sol.png
index 4eadee97..df11d438 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelist.sol.png and b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelist.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistBase.sol.png
index 2db56c32..a30ef0c8 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistOwnable2Step.sol.png
index d687e142..87e708a9 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleSpenderWhitelistOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelist.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelist.sol.png
index 86a8825f..3e01b79d 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelist.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelist.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistBase.sol.png
index 0d7ea6b7..d0514cd8 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistOwnable2Step.sol.png
index 87c918ba..644b4aaf 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistShared.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistShared.sol.png
index 54b5e88e..e40bd02d 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistShared.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistShared.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapper.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapper.sol.png
index d521009b..57aa6cd8 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapper.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapper.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperBase.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperBase.sol.png
index 35239337..26293b2f 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperBase.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperBase.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperOwnable2Step.sol.png b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperOwnable2Step.sol.png
index 719eecdc..a4d55f6c 100644
Binary files a/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperOwnable2Step.sol.png and b/doc/surya/surya_graph/surya_graph_RuleWhitelistWrapperOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_SanctionsListDelegationHarness.sol.png b/doc/surya/surya_graph/surya_graph_SanctionsListDelegationHarness.sol.png
new file mode 100644
index 00000000..ebffba77
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_SanctionsListDelegationHarness.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_TokenSupplyReader.sol.png b/doc/surya/surya_graph/surya_graph_TokenSupplyReader.sol.png
new file mode 100644
index 00000000..2907a3c5
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_TokenSupplyReader.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_TotalSupplyCapManager.sol.png b/doc/surya/surya_graph/surya_graph_TotalSupplyCapManager.sol.png
new file mode 100644
index 00000000..163bdd1e
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_TotalSupplyCapManager.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_TotalSupplyDecimalsMock.sol.png b/doc/surya/surya_graph/surya_graph_TotalSupplyDecimalsMock.sol.png
new file mode 100644
index 00000000..46755a66
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_TotalSupplyDecimalsMock.sol.png differ
diff --git a/doc/surya/surya_graph/surya_graph_VirtualHookOverrideHarnesses.sol.png b/doc/surya/surya_graph/surya_graph_VirtualHookOverrideHarnesses.sol.png
new file mode 100644
index 00000000..8e8fbaf0
Binary files /dev/null and b/doc/surya/surya_graph/surya_graph_VirtualHookOverrideHarnesses.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_AddressListInterfaceId.sol.png b/doc/surya/surya_inheritance/surya_inheritance_AddressListInterfaceId.sol.png
new file mode 100644
index 00000000..84fb0d69
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_AddressListInterfaceId.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_AddressSetBatchLib.sol.png b/doc/surya/surya_inheritance/surya_inheritance_AddressSetBatchLib.sol.png
new file mode 100644
index 00000000..8878d68f
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_AddressSetBatchLib.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Interface.sol.png b/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Interface.sol.png
new file mode 100644
index 00000000..37368b5d
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Interface.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Mock.sol.png b/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Mock.sol.png
new file mode 100644
index 00000000..b317f0a7
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_AggregatorV3Mock.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_BalanceCapManager.sol.png b/doc/surya/surya_inheritance/surya_inheritance_BalanceCapManager.sol.png
new file mode 100644
index 00000000..4d48bc9b
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_BalanceCapManager.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_BalanceOfMock.sol.png b/doc/surya/surya_inheritance/surya_inheritance_BalanceOfMock.sol.png
new file mode 100644
index 00000000..5903c5b8
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_BalanceOfMock.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_ChainlinkPoRFeedManager.sol.png b/doc/surya/surya_inheritance/surya_inheritance_ChainlinkPoRFeedManager.sol.png
new file mode 100644
index 00000000..a2c417ec
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_ChainlinkPoRFeedManager.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_ERC3643TokenMock.sol.png b/doc/surya/surya_inheritance/surya_inheritance_ERC3643TokenMock.sol.png
new file mode 100644
index 00000000..7986469b
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_ERC3643TokenMock.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IAddressListInterfaceIdHelper.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IAddressListInterfaceIdHelper.sol.png
new file mode 100644
index 00000000..37a85955
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IAddressListInterfaceIdHelper.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IBalanceOf.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IBalanceOf.sol.png
new file mode 100644
index 00000000..7f585a29
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IBalanceOf.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IDecimals.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IDecimals.sol.png
new file mode 100644
index 00000000..79a55629
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IDecimals.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IIdentityRegistryERC3643.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IIdentityRegistryERC3643.sol.png
new file mode 100644
index 00000000..42636c14
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IIdentityRegistryERC3643.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelist.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelist.sol.png
new file mode 100644
index 00000000..acc0f48c
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelist.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistBase.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistBase.sol.png
new file mode 100644
index 00000000..ed8305c6
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistInvariantStorage.sol.png b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistInvariantStorage.sol.png
new file mode 100644
index 00000000..92ccb567
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_IdentityRegistryWhitelistInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_OnchainIdMock.sol.png b/doc/surya/surya_inheritance/surya_inheritance_OnchainIdMock.sol.png
new file mode 100644
index 00000000..b1a8bfc7
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_OnchainIdMock.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSet.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSet.sol.png
index c785da30..34cbd0e9 100644
Binary files a/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSet.sol.png and b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSet.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetInternal.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetInternal.sol.png
index 3e5ab3d6..675a56c2 100644
Binary files a/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetInternal.sol.png and b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetInternal.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetRolesStorage.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetRolesStorage.sol.png
new file mode 100644
index 00000000..b8610d28
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleAddressSetRolesStorage.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoR.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoR.sol.png
new file mode 100644
index 00000000..6217b8bc
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoR.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRBase.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRBase.sol.png
new file mode 100644
index 00000000..0228a0a6
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRBase.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRInvariantStorage.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRInvariantStorage.sol.png
new file mode 100644
index 00000000..5e7a0e07
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoRInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoROwnable2Step.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoROwnable2Step.sol.png
new file mode 100644
index 00000000..171d62e7
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleChainlinkPoROwnable2Step.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Base.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Base.sol.png
index f6c93726..26114418 100644
Binary files a/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Base.sol.png and b/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Base.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Internal.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Internal.sol.png
index 523a7278..5b6dd12b 100644
Binary files a/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Internal.sol.png and b/doc/surya/surya_inheritance/surya_inheritance_RuleERC2980Internal.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalance.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalance.sol.png
new file mode 100644
index 00000000..a2d8cb47
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalance.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceBase.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceBase.sol.png
new file mode 100644
index 00000000..10a21642
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceBase.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceInvariantStorage.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceInvariantStorage.sol.png
new file mode 100644
index 00000000..b918ec9b
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceOwnable2Step.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceOwnable2Step.sol.png
new file mode 100644
index 00000000..9e91019d
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxBalanceOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxTotalSupplyBase.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxTotalSupplyBase.sol.png
index 9e480de6..6d1aaacf 100644
Binary files a/doc/surya/surya_inheritance/surya_inheritance_RuleMaxTotalSupplyBase.sol.png and b/doc/surya/surya_inheritance/surya_inheritance_RuleMaxTotalSupplyBase.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelist.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelist.sol.png
new file mode 100644
index 00000000..fe94c00d
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelist.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistBase.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistBase.sol.png
new file mode 100644
index 00000000..86ba2abc
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistBase.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistHarnesses.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistHarnesses.sol.png
new file mode 100644
index 00000000..c992fe99
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistHarnesses.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistInvariantStorage.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistInvariantStorage.sol.png
new file mode 100644
index 00000000..a2b8869b
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistInvariantStorage.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistOwnable2Step.sol.png b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistOwnable2Step.sol.png
new file mode 100644
index 00000000..95a2be5c
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_RuleReceiverWhitelistOwnable2Step.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_SanctionsListDelegationHarness.sol.png b/doc/surya/surya_inheritance/surya_inheritance_SanctionsListDelegationHarness.sol.png
new file mode 100644
index 00000000..13e30a45
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_SanctionsListDelegationHarness.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_TokenSupplyReader.sol.png b/doc/surya/surya_inheritance/surya_inheritance_TokenSupplyReader.sol.png
new file mode 100644
index 00000000..2082319c
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_TokenSupplyReader.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyCapManager.sol.png b/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyCapManager.sol.png
new file mode 100644
index 00000000..1b655520
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyCapManager.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyDecimalsMock.sol.png b/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyDecimalsMock.sol.png
new file mode 100644
index 00000000..eae4060f
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_TotalSupplyDecimalsMock.sol.png differ
diff --git a/doc/surya/surya_inheritance/surya_inheritance_VirtualHookOverrideHarnesses.sol.png b/doc/surya/surya_inheritance/surya_inheritance_VirtualHookOverrideHarnesses.sol.png
new file mode 100644
index 00000000..07d74219
Binary files /dev/null and b/doc/surya/surya_inheritance/surya_inheritance_VirtualHookOverrideHarnesses.sol.png differ
diff --git a/doc/surya/surya_report/surya_report_AccessControlModuleStandalone.sol.md b/doc/surya/surya_report/surya_report_AccessControlModuleStandalone.sol.md
index 3b9fb799..9567bb83 100644
--- a/doc/surya/surya_report/surya_report_AccessControlModuleStandalone.sol.md
+++ b/doc/surya/surya_report/surya_report_AccessControlModuleStandalone.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./modules/AccessControlModuleStandalone.sol | 1c7c0ffeb2ce2999fb14155cf49b1bb2339cc335 |
+| ./modules/AccessControlModuleStandalone.sol | d85f8a4199ba5fafba25e7fa2990eab7fa8f9af2 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_AddressListInterfaceId.sol.md b/doc/surya/surya_report/surya_report_AddressListInterfaceId.sol.md
new file mode 100644
index 00000000..6c2aa2a1
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_AddressListInterfaceId.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/interfaces/library/AddressListInterfaceId.sol | 8b08df55a6b20867989fffca8059fb09e3f5c39d |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **AddressListInterfaceId** | Library | |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_AddressSetBatchLib.sol.md b/doc/surya/surya_report/surya_report_AddressSetBatchLib.sol.md
new file mode 100644
index 00000000..2b63c2aa
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_AddressSetBatchLib.sol.md
@@ -0,0 +1,28 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol | 1b09ddf8af7c9b32fc572b329b1786122a132dad |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **AddressSetBatchLib** | Library | |||
+| └ | addBatch | Internal 🔒 | 🛑 | |
+| └ | removeBatch | Internal 🔒 | 🛑 | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_AggregatorV3Interface.sol.md b/doc/surya/surya_report/surya_report_AggregatorV3Interface.sol.md
new file mode 100644
index 00000000..aabb0b3a
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_AggregatorV3Interface.sol.md
@@ -0,0 +1,31 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/interfaces/AggregatorV3Interface.sol | 34368a1aa3db1abd327fa122177121de654cb335 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **AggregatorV3Interface** | Interface | |||
+| └ | decimals | External ❗️ | |NO❗️ |
+| └ | description | External ❗️ | |NO❗️ |
+| └ | version | External ❗️ | |NO❗️ |
+| └ | getRoundData | External ❗️ | |NO❗️ |
+| └ | latestRoundData | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_AggregatorV3Mock.sol.md b/doc/surya/surya_report/surya_report_AggregatorV3Mock.sol.md
new file mode 100644
index 00000000..304624db
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_AggregatorV3Mock.sol.md
@@ -0,0 +1,37 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/AggregatorV3Mock.sol | 3e21c9d393d555a54881080553072e74a00704ce |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **AggregatorV3Mock** | Implementation | AggregatorV3Interface |||
+| └ | | Public ❗️ | 🛑 |NO❗️ |
+| └ | setAnswer | External ❗️ | 🛑 |NO❗️ |
+| └ | setUpdatedAt | External ❗️ | 🛑 |NO❗️ |
+| └ | setDecimals | External ❗️ | 🛑 |NO❗️ |
+| └ | setRevertOnDecimals | External ❗️ | 🛑 |NO❗️ |
+| └ | setRevertOnLatestRoundData | External ❗️ | 🛑 |NO❗️ |
+| └ | decimals | External ❗️ | |NO❗️ |
+| └ | description | External ❗️ | |NO❗️ |
+| └ | version | External ❗️ | |NO❗️ |
+| └ | getRoundData | External ❗️ | |NO❗️ |
+| └ | latestRoundData | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_BalanceCapManager.sol.md b/doc/surya/surya_report/surya_report_BalanceCapManager.sol.md
new file mode 100644
index 00000000..de244d81
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_BalanceCapManager.sol.md
@@ -0,0 +1,42 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/core/BalanceCapManager.sol | e9fc2e355458aed8576d8aa0c26daaa9b3b88650 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **BalanceCapManager** | Implementation | RuleAddressSetInternal, RuleMaxBalanceInvariantStorage |||
+| └ | setMaxBalance | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | setBalanceToken | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | addExemptAddress | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | removeExemptAddress | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | addExemptAddresses | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | removeExemptAddresses | Public ❗️ | 🛑 | onlyMaxBalanceManager |
+| └ | isExemptAddress | Public ❗️ | |NO❗️ |
+| └ | exemptAddressCount | Public ❗️ | |NO❗️ |
+| └ | _addExemptAddress | Internal 🔒 | 🛑 | |
+| └ | _removeExemptAddress | Internal 🔒 | 🛑 | |
+| └ | _setMaxBalance | Internal 🔒 | 🛑 | |
+| └ | _setBalanceToken | Internal 🔒 | 🛑 | |
+| └ | _authorizeMaxBalanceManager | Internal 🔒 | | |
+| └ | _remainingCapacity | Internal 🔒 | | |
+| └ | _balanceOf | Internal 🔒 | | |
+| └ | _capExceeded | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_BalanceOfMock.sol.md b/doc/surya/surya_report/surya_report_BalanceOfMock.sol.md
new file mode 100644
index 00000000..abe0c7c7
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_BalanceOfMock.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/BalanceOfMock.sol | dfe90a976b017758577cde4b6a9ccd182d781503 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **BalanceOfMock** | Implementation | |||
+| └ | setBalance | External ❗️ | 🛑 |NO❗️ |
+| └ | setReverting | External ❗️ | 🛑 |NO❗️ |
+| └ | balanceOf | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_ChainlinkPoRFeedManager.sol.md b/doc/surya/surya_report/surya_report_ChainlinkPoRFeedManager.sol.md
new file mode 100644
index 00000000..b48f64af
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_ChainlinkPoRFeedManager.sol.md
@@ -0,0 +1,38 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/core/ChainlinkPoRFeedManager.sol | 4c296843d471b9cc60a86ef8253205dabbc45558 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **ChainlinkPoRFeedManager** | Implementation | TokenSupplyReader, RuleChainlinkPoRInvariantStorage |||
+| └ | setReservesFeed | Public ❗️ | 🛑 | onlyChainlinkPoRManager |
+| └ | setTokenMetadata | Public ❗️ | 🛑 | onlyChainlinkPoRManager |
+| └ | setMaxStalenessSeconds | Public ❗️ | 🛑 | onlyChainlinkPoRManager |
+| └ | feedDecimals | Public ❗️ | |NO❗️ |
+| └ | maxBackedSupply | Public ❗️ | |NO❗️ |
+| └ | _setReservesFeed | Internal 🔒 | 🛑 | |
+| └ | _setTokenMetadata | Internal 🔒 | 🛑 | |
+| └ | _setMaxStalenessSeconds | Internal 🔒 | 🛑 | |
+| └ | _authorizeChainlinkPoRManager | Internal 🔒 | | |
+| └ | _maxBackedSupply | Internal 🔒 | | |
+| └ | _supplyToken | Internal 🔒 | | |
+| └ | _scaleReserve | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_DeploymentCoverageHarnesses.sol.md b/doc/surya/surya_report/surya_report_DeploymentCoverageHarnesses.sol.md
index 9d4f2b78..a008760d 100644
--- a/doc/surya/surya_report/surya_report_DeploymentCoverageHarnesses.sol.md
+++ b/doc/surya/surya_report/surya_report_DeploymentCoverageHarnesses.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/harness/DeploymentCoverageHarnesses.sol | 51b794406806bfd8380bb4177ab3272724702184 |
+| ./mocks/harness/DeploymentCoverageHarnesses.sol | 318354698d593f0f988a414292fb84ed4213ea73 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_ERC3643TokenMock.sol.md b/doc/surya/surya_report/surya_report_ERC3643TokenMock.sol.md
new file mode 100644
index 00000000..65981209
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_ERC3643TokenMock.sol.md
@@ -0,0 +1,47 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/ERC3643TokenMock.sol | 09e3ec529557577b366c9e48ba1be2ed6fda0d4f |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IERC3643ComplianceForToken** | Interface | |||
+| └ | bindToken | External ❗️ | 🛑 |NO❗️ |
+| └ | unbindToken | External ❗️ | 🛑 |NO❗️ |
+| └ | transferred | External ❗️ | 🛑 |NO❗️ |
+| └ | created | External ❗️ | 🛑 |NO❗️ |
+| └ | destroyed | External ❗️ | 🛑 |NO❗️ |
+| └ | canTransfer | External ❗️ | |NO❗️ |
+||||||
+| **ERC3643TokenMock** | Implementation | |||
+| └ | | Public ❗️ | 🛑 |NO❗️ |
+| └ | setIdentityRegistry | External ❗️ | 🛑 |NO❗️ |
+| └ | setCompliance | External ❗️ | 🛑 |NO❗️ |
+| └ | setAgent | External ❗️ | 🛑 |NO❗️ |
+| └ | transfer | External ❗️ | 🛑 |NO❗️ |
+| └ | transferFrom | External ❗️ | 🛑 |NO❗️ |
+| └ | mint | External ❗️ | 🛑 | onlyAgent |
+| └ | burn | External ❗️ | 🛑 | onlyAgent |
+| └ | recoveryAddress | External ❗️ | 🛑 | onlyAgent |
+| └ | forcedTransfer | Public ❗️ | 🛑 | onlyAgent |
+| └ | _complianceTransferred | Internal 🔒 | 🛑 | |
+| └ | _transfer | Internal 🔒 | 🛑 | |
+| └ | _canTransfer | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IAddressList.sol.md b/doc/surya/surya_report/surya_report_IAddressList.sol.md
index a055f5a8..4282f5c6 100644
--- a/doc/surya/surya_report/surya_report_IAddressList.sol.md
+++ b/doc/surya/surya_report/surya_report_IAddressList.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/IAddressList.sol | 6470e15510efea5ed2dbdfa826ce74e2b7aa91b8 |
+| ./rules/interfaces/IAddressList.sol | e043af3e25afec3f5015f668979e5b32cc36f490 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_IAddressListInterfaceIdHelper.sol.md b/doc/surya/surya_report/surya_report_IAddressListInterfaceIdHelper.sol.md
new file mode 100644
index 00000000..7d61a2c1
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IAddressListInterfaceIdHelper.sol.md
@@ -0,0 +1,40 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/IAddressListInterfaceIdHelper.sol | 6a5a6a8bd3fd9815c99fdc78e654059fac6ac803 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IAddressListAllFunctions** | Interface | |||
+| └ | addAddresses | External ❗️ | 🛑 |NO❗️ |
+| └ | removeAddresses | External ❗️ | 🛑 |NO❗️ |
+| └ | addAddress | External ❗️ | 🛑 |NO❗️ |
+| └ | removeAddress | External ❗️ | 🛑 |NO❗️ |
+| └ | listedAddressCount | External ❗️ | |NO❗️ |
+| └ | isAddressListed | External ❗️ | |NO❗️ |
+| └ | areAddressesListed | External ❗️ | |NO❗️ |
+| └ | contains | External ❗️ | |NO❗️ |
+||||||
+| **IAddressListInterfaceIdHelper** | Implementation | |||
+| └ | getIAddressListInterfaceId | External ❗️ | |NO❗️ |
+| └ | getIAddressListAllFunctionsInterfaceId | External ❗️ | |NO❗️ |
+| └ | getAddressListInterfaceIdConstant | External ❗️ | |NO❗️ |
+| └ | getIIdentityRegistryContainsInterfaceId | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IBalanceOf.sol.md b/doc/surya/surya_report/surya_report_IBalanceOf.sol.md
new file mode 100644
index 00000000..e751f996
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IBalanceOf.sol.md
@@ -0,0 +1,27 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/interfaces/IBalanceOf.sol | b929f79eea73ae74eed29be94200ec1617f113f0 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IBalanceOf** | Interface | |||
+| └ | balanceOf | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IDecimals.sol.md b/doc/surya/surya_report/surya_report_IDecimals.sol.md
new file mode 100644
index 00000000..d388d130
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IDecimals.sol.md
@@ -0,0 +1,27 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/interfaces/IDecimals.sol | 1e05c1a66e9c16c176f35007b168a5870b4ae01b |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IDecimals** | Interface | |||
+| └ | decimals | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IERC2980.sol.md b/doc/surya/surya_report/surya_report_IERC2980.sol.md
index 984e55cd..c5b22db8 100644
--- a/doc/surya/surya_report/surya_report_IERC2980.sol.md
+++ b/doc/surya/surya_report/surya_report_IERC2980.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/IERC2980.sol | 11ed42e834b5471a3a2aa81bfd20748e7535f075 |
+| ./rules/interfaces/IERC2980.sol | 572923f223f79c9c8abebeaaae2bdefa3e0f48ec |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_IERC3643ComplianceFull.sol.md b/doc/surya/surya_report/surya_report_IERC3643ComplianceFull.sol.md
index 9d4116ba..60e22cd3 100644
--- a/doc/surya/surya_report/surya_report_IERC3643ComplianceFull.sol.md
+++ b/doc/surya/surya_report/surya_report_IERC3643ComplianceFull.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/IERC3643ComplianceFull.sol | 544dbc778c45d888ee9cdb28f507df53fe055edf |
+| ./mocks/IERC3643ComplianceFull.sol | 341ca7a53aeacd897ee359d5e80c1ec7f1fcf6fa |
### Contracts Description Table
@@ -16,14 +16,14 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **IERC3643ComplianceFull** | Interface | |||
-| └ | canTransfer | External ❗️ | |NO❗️ |
| └ | transferred | External ❗️ | 🛑 |NO❗️ |
| └ | bindToken | External ❗️ | 🛑 |NO❗️ |
| └ | unbindToken | External ❗️ | 🛑 |NO❗️ |
-| └ | isTokenBound | External ❗️ | |NO❗️ |
-| └ | getTokenBound | External ❗️ | |NO❗️ |
| └ | created | External ❗️ | 🛑 |NO❗️ |
| └ | destroyed | External ❗️ | 🛑 |NO❗️ |
+| └ | canTransfer | External ❗️ | |NO❗️ |
+| └ | isTokenBound | External ❗️ | |NO❗️ |
+| └ | getTokenBound | External ❗️ | |NO❗️ |
### Legend
diff --git a/doc/surya/surya_report/surya_report_IERC7943NonFungibleCompliance.sol.md b/doc/surya/surya_report/surya_report_IERC7943NonFungibleCompliance.sol.md
index d3117c26..83457588 100644
--- a/doc/surya/surya_report/surya_report_IERC7943NonFungibleCompliance.sol.md
+++ b/doc/surya/surya_report/surya_report_IERC7943NonFungibleCompliance.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/IERC7943NonFungibleCompliance.sol | 056034ef3844a682334b6a1d880e5144af4f6eb2 |
+| ./rules/interfaces/IERC7943NonFungibleCompliance.sol | 55871969a9e16b8fce437cf48ebcd3f4a33ee99f |
### Contracts Description Table
@@ -19,11 +19,11 @@
| └ | canTransfer | External ❗️ | |NO❗️ |
||||||
| **IERC7943NonFungibleComplianceExtend** | Interface | IERC7943NonFungibleCompliance |||
-| └ | detectTransferRestriction | External ❗️ | |NO❗️ |
-| └ | detectTransferRestrictionFrom | External ❗️ | |NO❗️ |
| └ | canTransferFrom | External ❗️ | 🛑 |NO❗️ |
| └ | transferred | External ❗️ | 🛑 |NO❗️ |
| └ | transferred | External ❗️ | 🛑 |NO❗️ |
+| └ | detectTransferRestriction | External ❗️ | |NO❗️ |
+| └ | detectTransferRestrictionFrom | External ❗️ | |NO❗️ |
### Legend
diff --git a/doc/surya/surya_report/surya_report_IIdentityRegistry.sol.md b/doc/surya/surya_report/surya_report_IIdentityRegistry.sol.md
index d6fbc511..945ab03b 100644
--- a/doc/surya/surya_report/surya_report_IIdentityRegistry.sol.md
+++ b/doc/surya/surya_report/surya_report_IIdentityRegistry.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/IIdentityRegistry.sol | 0e3619965759ec47953fff27a46ebc69bf6f2483 |
+| ./rules/interfaces/IIdentityRegistry.sol | b1b6798d6952e4edfa2f54da544d46125411374d |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_IIdentityRegistryERC3643.sol.md b/doc/surya/surya_report/surya_report_IIdentityRegistryERC3643.sol.md
new file mode 100644
index 00000000..578e673a
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IIdentityRegistryERC3643.sol.md
@@ -0,0 +1,33 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./registry/interfaces/IIdentityRegistryERC3643.sol | 851c11bf58b26370bf18076ddbba7f5de12c44de |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IIdentityRegistryERC3643** | Interface | |||
+| └ | registerIdentity | External ❗️ | 🛑 |NO❗️ |
+| └ | deleteIdentity | External ❗️ | 🛑 |NO❗️ |
+| └ | isVerified | External ❗️ | |NO❗️ |
+| └ | investorCountry | External ❗️ | |NO❗️ |
+||||||
+| **IERC734KeyHasPurpose** | Interface | |||
+| └ | keyHasPurpose | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_ISanctionsList.sol.md b/doc/surya/surya_report/surya_report_ISanctionsList.sol.md
index 5f9acafb..c0cc11af 100644
--- a/doc/surya/surya_report/surya_report_ISanctionsList.sol.md
+++ b/doc/surya/surya_report/surya_report_ISanctionsList.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/ISanctionsList.sol | e42d721fd4b6938e759e3d47843660707ee55f24 |
+| ./rules/interfaces/ISanctionsList.sol | 37c234b1876be59364a54ad2120828565154567a |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_ITotalSupply.sol.md b/doc/surya/surya_report/surya_report_ITotalSupply.sol.md
index f65be548..95061def 100644
--- a/doc/surya/surya_report/surya_report_ITotalSupply.sol.md
+++ b/doc/surya/surya_report/surya_report_ITotalSupply.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/ITotalSupply.sol | fdd5abfb3c5ed51e1f7b0f3d6dcaa2677bf5cdcd |
+| ./rules/interfaces/ITotalSupply.sol | 952fe9fcc0b0d3aa5d2ee777fe862785763dbd7a |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_ITransferContext.sol.md b/doc/surya/surya_report/surya_report_ITransferContext.sol.md
index 38905e0f..88ed7579 100644
--- a/doc/surya/surya_report/surya_report_ITransferContext.sol.md
+++ b/doc/surya/surya_report/surya_report_ITransferContext.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/interfaces/ITransferContext.sol | 3303d7a0948bc9d850c589678af416f49eab532e |
+| ./rules/interfaces/ITransferContext.sol | 488af7ebe62ce42d52770738154a04a7835241ce |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_IdentityRegistryMock.sol.md b/doc/surya/surya_report/surya_report_IdentityRegistryMock.sol.md
index 85a97b1c..8bcba975 100644
--- a/doc/surya/surya_report/surya_report_IdentityRegistryMock.sol.md
+++ b/doc/surya/surya_report/surya_report_IdentityRegistryMock.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/IdentityRegistryMock.sol | 52729f353f3783df05156fb7703e84d39b84167e |
+| ./mocks/IdentityRegistryMock.sol | 3331334b0165ab1b7140bad7e6b9325299899e60 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_IdentityRegistryWhitelist.sol.md b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelist.sol.md
new file mode 100644
index 00000000..aebd66e9
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelist.sol.md
@@ -0,0 +1,28 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./registry/IdentityRegistryWhitelist.sol | 97e256cb54c331930e96e508993919d68d0db772 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IdentityRegistryWhitelist** | Implementation | AccessControlModuleStandalone, IdentityRegistryWhitelistBase |||
+| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone |
+| └ | _authorizeIdentityRegistrar | Internal 🔒 | | onlyRole |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistBase.sol.md b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistBase.sol.md
new file mode 100644
index 00000000..84ba3fba
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistBase.sol.md
@@ -0,0 +1,32 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./registry/abstract/IdentityRegistryWhitelistBase.sol | 40c79099b5974626719f5083aaa0afe824dd7b1e |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IdentityRegistryWhitelistBase** | Implementation | RuleAddressSetInternal, VersionModule, IIdentityRegistryERC3643, IdentityRegistryWhitelistInvariantStorage |||
+| └ | registerIdentity | External ❗️ | 🛑 | onlyIdentityRegistrar |
+| └ | deleteIdentity | External ❗️ | 🛑 | onlyIdentityRegistrar |
+| └ | registeredIdentityCount | External ❗️ | |NO❗️ |
+| └ | isVerified | Public ❗️ | |NO❗️ |
+| └ | investorCountry | Public ❗️ | |NO❗️ |
+| └ | _authorizeIdentityRegistrar | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistInvariantStorage.sol.md
new file mode 100644
index 00000000..39f28390
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_IdentityRegistryWhitelistInvariantStorage.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol | 72bf3581f2b5316a3520e350403477434d1c7c3b |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **IdentityRegistryWhitelistInvariantStorage** | Implementation | |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_MetaTxModuleStandalone.sol.md b/doc/surya/surya_report/surya_report_MetaTxModuleStandalone.sol.md
index 332a4ea9..741bb8c3 100644
--- a/doc/surya/surya_report/surya_report_MetaTxModuleStandalone.sol.md
+++ b/doc/surya/surya_report/surya_report_MetaTxModuleStandalone.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./modules/MetaTxModuleStandalone.sol | 6467a67732560062997934fc1852cd96eda1fab3 |
+| ./modules/MetaTxModuleStandalone.sol | 9e67804bb9f848d4ae06efbc059d0e1f1c86c2a7 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_MockERC20TransferFromFalse.sol.md b/doc/surya/surya_report/surya_report_MockERC20TransferFromFalse.sol.md
index 397613c8..137924de 100644
--- a/doc/surya/surya_report/surya_report_MockERC20TransferFromFalse.sol.md
+++ b/doc/surya/surya_report/surya_report_MockERC20TransferFromFalse.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/MockERC20TransferFromFalse.sol | 7e9fe543cc9f7a8e3f6ec3b8285d70526b2639c7 |
+| ./mocks/MockERC20TransferFromFalse.sol | 4a6c653e785dcc2b9672e85f5376460c53892864 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_MockERC20WithTransferContext.sol.md b/doc/surya/surya_report/surya_report_MockERC20WithTransferContext.sol.md
index 0ebab994..221df1d1 100644
--- a/doc/surya/surya_report/surya_report_MockERC20WithTransferContext.sol.md
+++ b/doc/surya/surya_report/surya_report_MockERC20WithTransferContext.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/MockERC20WithTransferContext.sol | 657e5680617872717c6a5b9e9f801c5c4594b821 |
+| ./mocks/MockERC20WithTransferContext.sol | 800393df6fa9abddd61597ad786c0a0dbade9fcd |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_MockERC721WithTransferContext.sol.md b/doc/surya/surya_report/surya_report_MockERC721WithTransferContext.sol.md
index 6a95fcac..e2e93e19 100644
--- a/doc/surya/surya_report/surya_report_MockERC721WithTransferContext.sol.md
+++ b/doc/surya/surya_report/surya_report_MockERC721WithTransferContext.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/MockERC721WithTransferContext.sol | 7f7b1da23845193ea703336af8bf0c8f4f66956d |
+| ./mocks/MockERC721WithTransferContext.sol | 1677006b8405958e1bc74504b1b438b06a62bedd |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_OnchainIdMock.sol.md b/doc/surya/surya_report/surya_report_OnchainIdMock.sol.md
new file mode 100644
index 00000000..2d72ed12
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_OnchainIdMock.sol.md
@@ -0,0 +1,28 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/OnchainIdMock.sol | ef43bae78c7bc26f3a98dc3ed95bcd9d49b322f0 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **OnchainIdMock** | Implementation | IERC734KeyHasPurpose |||
+| └ | addWalletKey | External ❗️ | 🛑 |NO❗️ |
+| └ | keyHasPurpose | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_Ownable2StepERC165Module.sol.md b/doc/surya/surya_report/surya_report_Ownable2StepERC165Module.sol.md
index 23831457..7a165719 100644
--- a/doc/surya/surya_report/surya_report_Ownable2StepERC165Module.sol.md
+++ b/doc/surya/surya_report/surya_report_Ownable2StepERC165Module.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./modules/Ownable2StepERC165Module.sol | 50ac2979efcf101b8db4fbc27720e9e92fedd47a |
+| ./modules/Ownable2StepERC165Module.sol | 321912f22c23d00d708920cc10e637c2b23086ed |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleAddressSet.sol.md b/doc/surya/surya_report/surya_report_RuleAddressSet.sol.md
index 5c2fcf6f..360e212e 100644
--- a/doc/surya/surya_report/surya_report_RuleAddressSet.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleAddressSet.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol | 2b2c138a17fd651aa8bc8346df863c7fc08ffeb3 |
+| ./rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol | 873e7ec6aa710f67defc0d61794233df402867ea |
### Contracts Description Table
@@ -15,10 +15,8 @@
|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
-| **RuleAddressSet** | Implementation | MetaTxModuleStandalone, RuleAddressSetInternal, RuleAddressSetInvariantStorage, IAddressList |||
+| **RuleAddressSet** | Implementation | MetaTxModuleStandalone, RuleAddressSetInvariantStorage, RuleAddressSetRolesStorage, RuleAddressSetInternal, IAddressList |||
| └ | | Public ❗️ | 🛑 | MetaTxModuleStandalone |
-| └ | _authorizeAddressListAdd | Internal 🔒 | | |
-| └ | _authorizeAddressListRemove | Internal 🔒 | | |
| └ | addAddresses | Public ❗️ | 🛑 | onlyAddressListAdd |
| └ | removeAddresses | Public ❗️ | 🛑 | onlyAddressListRemove |
| └ | addAddress | Public ❗️ | 🛑 | onlyAddressListAdd |
@@ -27,6 +25,8 @@
| └ | contains | Public ❗️ | |NO❗️ |
| └ | isAddressListed | Public ❗️ | |NO❗️ |
| └ | areAddressesListed | Public ❗️ | |NO❗️ |
+| └ | _authorizeAddressListAdd | Internal 🔒 | | |
+| └ | _authorizeAddressListRemove | Internal 🔒 | | |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleAddressSetInternal.sol.md b/doc/surya/surya_report/surya_report_RuleAddressSetInternal.sol.md
index 4f536071..3438c7ab 100644
--- a/doc/surya/surya_report/surya_report_RuleAddressSetInternal.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleAddressSetInternal.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol | 1c962935e41f98f081c56290f77322eb6787ef14 |
+| ./rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol | eddc744d1e6c3c8a68aa267774d77536d986146f |
### Contracts Description Table
@@ -15,8 +15,9 @@
|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
-| **RuleAddressSetInternal** | Implementation | |||
+| **RuleAddressSetInternal** | Implementation | RuleAddressSetInvariantStorage |||
| └ | _addAddresses | Internal 🔒 | 🛑 | |
+| └ | _requireNotZeroAddress | Internal 🔒 | | |
| └ | _removeAddresses | Internal 🔒 | 🛑 | |
| └ | _addAddress | Internal 🔒 | 🛑 | |
| └ | _removeAddress | Internal 🔒 | 🛑 | |
diff --git a/doc/surya/surya_report/surya_report_RuleAddressSetInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleAddressSetInvariantStorage.sol.md
index 677d0827..9e644a12 100644
--- a/doc/surya/surya_report/surya_report_RuleAddressSetInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleAddressSetInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol | dbb32b305ba44a039e4c3783583d5e80ec237479 |
+| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol | 536a99fa8d83bc1ade4fb11ef4ff1be992247b12 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleAddressSetRolesStorage.sol.md b/doc/surya/surya_report/surya_report_RuleAddressSetRolesStorage.sol.md
new file mode 100644
index 00000000..0d3618fc
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleAddressSetRolesStorage.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol | 6f75529d3a7d9645316c498c0f36aa1afdc2ae48 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleAddressSetRolesStorage** | Implementation | |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleBlacklist.sol.md b/doc/surya/surya_report/surya_report_RuleBlacklist.sol.md
index cd6e0c23..25694559 100644
--- a/doc/surya/surya_report/surya_report_RuleBlacklist.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleBlacklist.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleBlacklist.sol | 6fb955444f634508bb273ed89b3e9c793a63ddd1 |
+| ./rules/validation/deployment/RuleBlacklist.sol | 34ce5c4b5a69fb30df067759c8c6b385e2e9c105 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleBlacklistBase.sol.md b/doc/surya/surya_report/surya_report_RuleBlacklistBase.sol.md
index 240f9d4d..29db00e9 100644
--- a/doc/surya/surya_report/surya_report_RuleBlacklistBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleBlacklistBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleBlacklistBase.sol | a9e7acac4f30169f321bcc9b36a932a8e100da63 |
+| ./rules/validation/abstract/base/RuleBlacklistBase.sol | 8abdb2e4e56f45a1ee470fa4b4951df84280a2d8 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleBlacklistInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleBlacklistInvariantStorage.sol.md
index bf4fe7ea..3c707b60 100644
--- a/doc/surya/surya_report/surya_report_RuleBlacklistInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleBlacklistInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol | 4d408462eab6f20ab07dc32a28e324a2d872a6a1 |
+| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol | 3550f3c4167e9fa8f9ac47298302a700a326d69a |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleBlacklistOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleBlacklistOwnable2Step.sol.md
index 8fadfa02..7083e042 100644
--- a/doc/surya/surya_report/surya_report_RuleBlacklistOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleBlacklistOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleBlacklistOwnable2Step.sol | d1d803fa9f1f3db0a21ef8cde1b9a96e910cd728 |
+| ./rules/validation/deployment/RuleBlacklistOwnable2Step.sol | 0dae4103b3e753c271e605b161674841e0811192 |
### Contracts Description Table
@@ -17,9 +17,9 @@
||||||
| **RuleBlacklistOwnable2Step** | Implementation | RuleBlacklistBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleBlacklistBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyOwner |
| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyOwner |
-| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleChainlinkPoR.sol.md b/doc/surya/surya_report/surya_report_RuleChainlinkPoR.sol.md
new file mode 100644
index 00000000..9aacd7c1
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleChainlinkPoR.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleChainlinkPoR.sol | abaa0814b84d2d47196727433bfba443396ac372 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleChainlinkPoR** | Implementation | AccessControlModuleStandalone, RuleChainlinkPoRBase |||
+| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone RuleChainlinkPoRBase |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeChainlinkPoRManager | Internal 🔒 | | onlyRole |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleChainlinkPoRBase.sol.md b/doc/surya/surya_report/surya_report_RuleChainlinkPoRBase.sol.md
new file mode 100644
index 00000000..b50ed54e
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleChainlinkPoRBase.sol.md
@@ -0,0 +1,35 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/base/RuleChainlinkPoRBase.sol | a1e200478baa5885cb422102d6800a094cf5abda |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleChainlinkPoRBase** | Implementation | RuleTransferValidation, ChainlinkPoRFeedManager |||
+| └ | | Public ❗️ | 🛑 |NO❗️ |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | _detectTransferRestriction | Internal 🔒 | | |
+| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
+| └ | _transferred | Internal 🔒 | | |
+| └ | _transferredFrom | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleChainlinkPoRInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleChainlinkPoRInvariantStorage.sol.md
new file mode 100644
index 00000000..a7a008ef
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleChainlinkPoRInvariantStorage.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol | 64a0b4372644a3b79b325839cdd9897f5c3ad9f0 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleChainlinkPoRInvariantStorage** | Implementation | RuleSharedInvariantStorage |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleChainlinkPoROwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleChainlinkPoROwnable2Step.sol.md
new file mode 100644
index 00000000..3d8cccfa
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleChainlinkPoROwnable2Step.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol | 61b5c28951040945c0f5d14c971fc5114a396df3 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleChainlinkPoROwnable2Step** | Implementation | RuleChainlinkPoRBase, Ownable2Step, Ownable2StepERC165Module |||
+| └ | | Public ❗️ | 🛑 | RuleChainlinkPoRBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeChainlinkPoRManager | Internal 🔒 | | onlyOwner |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLight.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLight.sol.md
index d1056300..0c7f3f3c 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLight.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLight.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleConditionalTransferLight.sol | 62dd5be103c29fa0ad3ec6ee371468a8e0edc01f |
+| ./rules/operation/RuleConditionalTransferLight.sol | d20cf57626e59b0e16ae7d8ae18d7c7f1c1de7a5 |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleConditionalTransferLight** | Implementation | AccessControlModuleStandalone, RuleConditionalTransferLightBase, ERC3643ComplianceRolesStorage |||
| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyRole |
| └ | _authorizeTransferApproval | Internal 🔒 | | onlyRole |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyRole |
| └ | _authorizeComplianceBindingChange | Internal 🔒 | | onlyRole |
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightApprovalBase.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightApprovalBase.sol.md
index 2b38f0b6..d38172c0 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightApprovalBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightApprovalBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol | 6aa6f8cfabdb795343debc1b501508f392428404 |
+| ./rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol | ac5eac80044e31e99f670ecc63ece95fa1f8326f |
### Contracts Description Table
@@ -16,15 +16,16 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **RuleConditionalTransferLightApprovalBase** | Implementation | RuleConditionalTransferLightInvariantStorage |||
-| └ | _authorizeTransferApproval | Internal 🔒 | | |
-| └ | _authorizeTransferExecution | Internal 🔒 | | |
| └ | transferred | External ❗️ | 🛑 | onlyTransferExecutor |
| └ | approveTransfer | Public ❗️ | 🛑 | onlyTransferApprover |
| └ | cancelTransferApproval | Public ❗️ | 🛑 | onlyTransferApprover |
+| └ | resetApproval | Public ❗️ | 🛑 | onlyTransferApprover |
| └ | approvedCount | Public ❗️ | |NO❗️ |
| └ | _transferredFromContext | Internal 🔒 | 🛑 | |
| └ | _transferred | Internal 🔒 | 🛑 | |
| └ | _transferHash | Internal 🔒 | | |
+| └ | _authorizeTransferApproval | Internal 🔒 | | |
+| └ | _authorizeTransferExecution | Internal 🔒 | | |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightBase.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightBase.sol.md
index 2f3938f1..8fd176df 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleConditionalTransferLightBase.sol | ca4e40de3d7c746518faf45ef82d6c21c846e650 |
+| ./rules/operation/abstract/RuleConditionalTransferLightBase.sol | 61bc28f3f3069112e53a7939da7cb4bfeca103f9 |
### Contracts Description Table
@@ -16,14 +16,17 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **RuleConditionalTransferLightBase** | Implementation | VersionModule, ERC3643ComplianceModule, RuleConditionalTransferLightApprovalBase, IRule |||
-| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
-| └ | messageForTransferRestriction | External ❗️ | |NO❗️ |
| └ | created | External ❗️ | 🛑 | onlyBoundToken |
| └ | destroyed | External ❗️ | 🛑 | onlyBoundToken |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
+| └ | messageForTransferRestriction | External ❗️ | |NO❗️ |
| └ | approveAndTransferIfAllowed | Public ❗️ | 🛑 | onlyTransferApprover |
| └ | transferred | Public ❗️ | 🛑 | onlyTransferExecutor |
| └ | transferred | Public ❗️ | 🛑 | onlyTransferExecutor |
| └ | bindToken | Public ❗️ | 🛑 | onlyComplianceManager |
+| └ | bindRuleEngine | Public ❗️ | 🛑 | onlyComplianceManager |
+| └ | unbindRuleEngine | Public ❗️ | 🛑 | onlyComplianceManager |
+| └ | isTransferExecutor | Public ❗️ | |NO❗️ |
| └ | detectTransferRestriction | Public ❗️ | |NO❗️ |
| └ | detectTransferRestrictionFrom | Public ❗️ | |NO❗️ |
| └ | canTransfer | Public ❗️ | |NO❗️ |
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightInvariantStorage.sol.md
index 99855f36..25e75f82 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol | 0acfbcd70d2a7a70fc2d244345cb363acff8def9 |
+| ./rules/operation/abstract/RuleConditionalTransferLightInvariantStorage.sol | b3d88b809eda5e44ef47c6e103a347c0c2ca92f8 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiToken.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiToken.sol.md
index 2d1dbe64..0e821388 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiToken.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiToken.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleConditionalTransferLightMultiToken.sol | 5a26c4179ebee4b52842ca0073933366fcb4ce6c |
+| ./rules/operation/RuleConditionalTransferLightMultiToken.sol | bc1bc27d2f80a0116ae1987cf795ab22032fce60 |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleConditionalTransferLightMultiToken** | Implementation | AccessControlModuleStandalone, RuleConditionalTransferLightMultiTokenBase, ERC3643ComplianceRolesStorage |||
| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyRole |
| └ | _authorizeTransferApproval | Internal 🔒 | | onlyRole |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyRole |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenBase.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenBase.sol.md
index a163edc1..89ea6ab0 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol | 005bde36fdb674f7b60284457c9206700fe85387 |
+| ./rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol | 126be4df25c91c0cace70424f0e0d608b1cf8258 |
### Contracts Description Table
@@ -16,27 +16,31 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **RuleConditionalTransferLightMultiTokenBase** | Implementation | VersionModule, ERC3643ComplianceModule, RuleConditionalTransferLightMultiTokenInvariantStorage, IRule |||
-| └ | _authorizeTransferApproval | Internal 🔒 | | |
-| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
-| └ | messageForTransferRestriction | External ❗️ | |NO❗️ |
| └ | created | External ❗️ | 🛑 | onlyBoundToken |
| └ | destroyed | External ❗️ | 🛑 | onlyBoundToken |
+| └ | transferred | External ❗️ | 🛑 | onlyTransferExecutor |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
+| └ | messageForTransferRestriction | External ❗️ | |NO❗️ |
| └ | approveTransfer | Public ❗️ | 🛑 | onlyTransferApprover |
| └ | cancelTransferApproval | Public ❗️ | 🛑 | onlyTransferApprover |
-| └ | approvedCount | Public ❗️ | |NO❗️ |
| └ | approveAndTransferIfAllowed | Public ❗️ | 🛑 | onlyTransferApprover |
| └ | transferred | Public ❗️ | 🛑 | onlyTransferExecutor |
| └ | transferred | Public ❗️ | 🛑 | onlyTransferExecutor |
-| └ | transferred | External ❗️ | 🛑 | onlyTransferExecutor |
+| └ | resetApproval | Public ❗️ | 🛑 | onlyTransferApprover |
+| └ | approvedCount | Public ❗️ | |NO❗️ |
| └ | detectTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | detectTransferRestrictionForToken | Public ❗️ | |NO❗️ |
+| └ | canTransferForToken | Public ❗️ | |NO❗️ |
| └ | detectTransferRestrictionFrom | Public ❗️ | |NO❗️ |
| └ | canTransfer | Public ❗️ | |NO❗️ |
| └ | canTransferFrom | Public ❗️ | |NO❗️ |
-| └ | _authorizeTransferExecution | Internal 🔒 | | |
| └ | _authorizeComplianceBindingChange | Internal 🔒 | 🛑 | |
| └ | _approveTransfer | Internal 🔒 | 🛑 | |
| └ | _cancelTransferApproval | Internal 🔒 | 🛑 | |
| └ | _transferred | Internal 🔒 | 🛑 | |
+| └ | _detectTransferRestrictionForToken | Internal 🔒 | | |
+| └ | _authorizeTransferExecution | Internal 🔒 | | |
+| └ | _authorizeTransferApproval | Internal 🔒 | | |
| └ | _transferHash | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenInvariantStorage.sol.md
index 997b9a42..3e19e3be 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol | 8376ee8d2ce771fdb57e13ef3832258220f89496 |
+| ./rules/operation/abstract/RuleConditionalTransferLightMultiTokenInvariantStorage.sol | 69581e98b6b327335f84cefe92a064e0189d9532 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.md
index 20808f5e..db3c2481 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightMultiTokenOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol | 3da3c69fd927f5e21b6881156369ff3c7957af38 |
+| ./rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol | e4023e744c8042d7fdc8c117a3c920f14d09e33c |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleConditionalTransferLightMultiTokenOwnable2Step** | Implementation | RuleConditionalTransferLightMultiTokenBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | Ownable |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyOwner |
| └ | _authorizeTransferApproval | Internal 🔒 | | onlyOwner |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyOwner |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightOwnable2Step.sol.md
index 440233a4..f6ead88e 100644
--- a/doc/surya/surya_report/surya_report_RuleConditionalTransferLightOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleConditionalTransferLightOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleConditionalTransferLightOwnable2Step.sol | e94c0dc5da33a08b1438eed005fffaf59d213def |
+| ./rules/operation/RuleConditionalTransferLightOwnable2Step.sol | 522390c69b70608392e43493c56e72056e45df93 |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleConditionalTransferLightOwnable2Step** | Implementation | RuleConditionalTransferLightBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | Ownable |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyOwner |
| └ | _authorizeTransferApproval | Internal 🔒 | | onlyOwner |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyOwner |
| └ | _authorizeComplianceBindingChange | Internal 🔒 | | onlyOwner |
diff --git a/doc/surya/surya_report/surya_report_RuleERC2980.sol.md b/doc/surya/surya_report/surya_report_RuleERC2980.sol.md
index 30beca3a..89c0d9dc 100644
--- a/doc/surya/surya_report/surya_report_RuleERC2980.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleERC2980.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleERC2980.sol | 7d6e48bf6d899b27e31232a81f74fd04f19d6e76 |
+| ./rules/validation/deployment/RuleERC2980.sol | df7b966dbe91c0ac24efcf6bc7f03cda96f3ed91 |
### Contracts Description Table
@@ -18,6 +18,7 @@
| **RuleERC2980** | Implementation | RuleERC2980Base, AccessControlModuleStandalone |||
| └ | | Public ❗️ | 🛑 | RuleERC2980Base AccessControlModuleStandalone |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyRole |
| └ | _authorizeWhitelistAdd | Internal 🔒 | | onlyRole |
| └ | _authorizeWhitelistRemove | Internal 🔒 | | onlyRole |
| └ | _authorizeFrozenlistAdd | Internal 🔒 | | onlyRole |
diff --git a/doc/surya/surya_report/surya_report_RuleERC2980Base.sol.md b/doc/surya/surya_report/surya_report_RuleERC2980Base.sol.md
index db7ccbb7..8f706e9b 100644
--- a/doc/surya/surya_report/surya_report_RuleERC2980Base.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleERC2980Base.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleERC2980Base.sol | 8cffbdad2d3c9179d43521bd57610ee8f3a47178 |
+| ./rules/validation/abstract/base/RuleERC2980Base.sol | 8f278a7b80cdc7c6829bb93d0f99b4a4b05cdedf |
### Contracts Description Table
@@ -15,12 +15,8 @@
|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
-| **RuleERC2980Base** | Implementation | MetaTxModuleStandalone, RuleERC2980Internal, RuleERC2980InvariantStorage, RuleNFTAdapter, IERC2980, IIdentityRegistryVerified |||
+| **RuleERC2980Base** | Implementation | MetaTxModuleStandalone, RuleERC2980InvariantStorage, RuleERC2980Internal, RuleNFTAdapter, IERC2980, IIdentityRegistryVerified |||
| └ | | Public ❗️ | 🛑 | MetaTxModuleStandalone |
-| └ | _authorizeWhitelistAdd | Internal 🔒 | | |
-| └ | _authorizeWhitelistRemove | Internal 🔒 | | |
-| └ | _authorizeFrozenlistAdd | Internal 🔒 | | |
-| └ | _authorizeFrozenlistRemove | Internal 🔒 | | |
| └ | addWhitelistAddresses | Public ❗️ | 🛑 | onlyWhitelistAdd |
| └ | removeWhitelistAddresses | Public ❗️ | 🛑 | onlyWhitelistRemove |
| └ | addWhitelistAddress | Public ❗️ | 🛑 | onlyWhitelistAdd |
@@ -29,6 +25,8 @@
| └ | removeFrozenlistAddresses | Public ❗️ | 🛑 | onlyFrozenlistRemove |
| └ | addFrozenlistAddress | Public ❗️ | 🛑 | onlyFrozenlistAdd |
| └ | removeFrozenlistAddress | Public ❗️ | 🛑 | onlyFrozenlistRemove |
+| └ | setAllowMint | Public ❗️ | 🛑 | onlyMintBurnManager |
+| └ | setAllowBurn | Public ❗️ | 🛑 | onlyMintBurnManager |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | canReturnTransferRestrictionCode | Public ❗️ | |NO❗️ |
@@ -43,6 +41,11 @@
| └ | isFrozen | Public ❗️ | |NO❗️ |
| └ | frozenlist | Public ❗️ | |NO❗️ |
| └ | areFrozen | Public ❗️ | |NO❗️ |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | |
+| └ | _authorizeWhitelistAdd | Internal 🔒 | | |
+| └ | _authorizeWhitelistRemove | Internal 🔒 | | |
+| └ | _authorizeFrozenlistAdd | Internal 🔒 | | |
+| └ | _authorizeFrozenlistRemove | Internal 🔒 | | |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleERC2980Internal.sol.md b/doc/surya/surya_report/surya_report_RuleERC2980Internal.sol.md
index 7df0ae1a..dd9dba73 100644
--- a/doc/surya/surya_report/surya_report_RuleERC2980Internal.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleERC2980Internal.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol | 084ab0430f52a08abdad8e38a4693255426a3058 |
+| ./rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol | 5566b1b8c48c5f5d89b01d7883f00743bd7bf8f2 |
### Contracts Description Table
@@ -15,17 +15,18 @@
|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
-| **RuleERC2980Internal** | Implementation | |||
+| **RuleERC2980Internal** | Implementation | RuleERC2980InvariantStorage |||
| └ | _addWhitelistAddresses | Internal 🔒 | 🛑 | |
| └ | _removeWhitelistAddresses | Internal 🔒 | 🛑 | |
| └ | _addWhitelistAddress | Internal 🔒 | 🛑 | |
| └ | _removeWhitelistAddress | Internal 🔒 | 🛑 | |
-| └ | _isWhitelisted | Internal 🔒 | | |
-| └ | _whitelistCount | Internal 🔒 | | |
| └ | _addFrozenlistAddresses | Internal 🔒 | 🛑 | |
| └ | _removeFrozenlistAddresses | Internal 🔒 | 🛑 | |
| └ | _addFrozenlistAddress | Internal 🔒 | 🛑 | |
| └ | _removeFrozenlistAddress | Internal 🔒 | 🛑 | |
+| └ | _requireNotZeroAddress | Internal 🔒 | | |
+| └ | _isWhitelisted | Internal 🔒 | | |
+| └ | _whitelistCount | Internal 🔒 | | |
| └ | _isFrozen | Internal 🔒 | | |
| └ | _frozenlistCount | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleERC2980InvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleERC2980InvariantStorage.sol.md
index 91125d67..7b20e045 100644
--- a/doc/surya/surya_report/surya_report_RuleERC2980InvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleERC2980InvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol | 7f95583b45c40e1ba4f8197421ed9fa2187a971f |
+| ./rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol | 077f022e41959ca712342763566a630c437e514a |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleERC2980Ownable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleERC2980Ownable2Step.sol.md
index 5ea80fa3..21ef994d 100644
--- a/doc/surya/surya_report/surya_report_RuleERC2980Ownable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleERC2980Ownable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleERC2980Ownable2Step.sol | 059ea6668baf03d1ac9f3c7f3c85e2b09c01306f |
+| ./rules/validation/deployment/RuleERC2980Ownable2Step.sol | 2293c553f36889c26a08a61e5bc7f62e92334be6 |
### Contracts Description Table
@@ -17,11 +17,12 @@
||||||
| **RuleERC2980Ownable2Step** | Implementation | RuleERC2980Base, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleERC2980Base Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyOwner |
| └ | _authorizeWhitelistAdd | Internal 🔒 | | onlyOwner |
| └ | _authorizeWhitelistRemove | Internal 🔒 | | onlyOwner |
| └ | _authorizeFrozenlistAdd | Internal 🔒 | | onlyOwner |
| └ | _authorizeFrozenlistRemove | Internal 🔒 | | onlyOwner |
-| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleIdentityRegistry.sol.md b/doc/surya/surya_report/surya_report_RuleIdentityRegistry.sol.md
index 0ebfdb01..d4925079 100644
--- a/doc/surya/surya_report/surya_report_RuleIdentityRegistry.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleIdentityRegistry.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleIdentityRegistry.sol | f48fe73ef47e1f914fb6de05c2542913f5813817 |
+| ./rules/validation/deployment/RuleIdentityRegistry.sol | 296272be3e4034076512b1a091172e2c5d08e0a1 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleIdentityRegistryBase.sol.md b/doc/surya/surya_report/surya_report_RuleIdentityRegistryBase.sol.md
index c3c84c06..59b1c345 100644
--- a/doc/surya/surya_report/surya_report_RuleIdentityRegistryBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleIdentityRegistryBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleIdentityRegistryBase.sol | 3314d7f11202a0210238d55ad78d0ee67339aadf |
+| ./rules/validation/abstract/base/RuleIdentityRegistryBase.sol | 2e24eefcee4613fe38e39197faa509d0b69cebbd |
### Contracts Description Table
@@ -17,13 +17,15 @@
||||||
| **RuleIdentityRegistryBase** | Implementation | RuleNFTAdapter, RuleIdentityRegistryInvariantStorage |||
| └ | | Public ❗️ | 🛑 |NO❗️ |
-| └ | _authorizeIdentityRegistryManager | Internal 🔒 | | |
| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
| └ | setIdentityRegistry | Public ❗️ | 🛑 | onlyIdentityRegistryManager |
+| └ | setCheckSender | Public ❗️ | 🛑 | onlyIdentityRegistryManager |
+| └ | setCheckSpender | Public ❗️ | 🛑 | onlyIdentityRegistryManager |
| └ | clearIdentityRegistry | Public ❗️ | 🛑 | onlyIdentityRegistryManager |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | _authorizeIdentityRegistryManager | Internal 🔒 | | |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleIdentityRegistryInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleIdentityRegistryInvariantStorage.sol.md
index 978372a4..949a464e 100644
--- a/doc/surya/surya_report/surya_report_RuleIdentityRegistryInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleIdentityRegistryInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol | fcd7549f3bcfd95fb8b052a1c88a4a04672d8df1 |
+| ./rules/validation/abstract/invariant/RuleIdentityRegistryInvariantStorage.sol | 17a3210247dfd4c16c6548cdb2e065ab5be0d130 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleIdentityRegistryOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleIdentityRegistryOwnable2Step.sol.md
index 49a88786..6837f068 100644
--- a/doc/surya/surya_report/surya_report_RuleIdentityRegistryOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleIdentityRegistryOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol | 99b6856a407f4a428a54b05ff817868f1d74e909 |
+| ./rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol | 5fae0ca575c1578cd3fa3937730d9657585d7e39 |
### Contracts Description Table
@@ -17,8 +17,8 @@
||||||
| **RuleIdentityRegistryOwnable2Step** | Implementation | RuleIdentityRegistryBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleIdentityRegistryBase Ownable |
-| └ | _authorizeIdentityRegistryManager | Internal 🔒 | | onlyOwner |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeIdentityRegistryManager | Internal 🔒 | | onlyOwner |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleMaxBalance.sol.md b/doc/surya/surya_report/surya_report_RuleMaxBalance.sol.md
new file mode 100644
index 00000000..fb5d3024
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleMaxBalance.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleMaxBalance.sol | 4429c1761c88f3f8a783f34f780758456a59384d |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleMaxBalance** | Implementation | AccessControlModuleStandalone, RuleMaxBalanceBase |||
+| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone RuleMaxBalanceBase |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMaxBalanceManager | Internal 🔒 | | onlyRole |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleMaxBalanceBase.sol.md b/doc/surya/surya_report/surya_report_RuleMaxBalanceBase.sol.md
new file mode 100644
index 00000000..678c8198
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleMaxBalanceBase.sol.md
@@ -0,0 +1,36 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/base/RuleMaxBalanceBase.sol | 62f929cdc9564258f602ff2a298f31a304d1c53f |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleMaxBalanceBase** | Implementation | RuleTransferValidation, BalanceCapManager |||
+| └ | | Public ❗️ | 🛑 |NO❗️ |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
+| └ | remainingCapacity | Public ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | _detectTransferRestriction | Internal 🔒 | | |
+| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
+| └ | _transferred | Internal 🔒 | | |
+| └ | _transferredFrom | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleMaxBalanceInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleMaxBalanceInvariantStorage.sol.md
new file mode 100644
index 00000000..d6578f6b
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleMaxBalanceInvariantStorage.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol | 961c13043e554ff196090204c1dc2f7d2c64c9d8 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleMaxBalanceInvariantStorage** | Implementation | RuleSharedInvariantStorage |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleMaxBalanceOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleMaxBalanceOwnable2Step.sol.md
new file mode 100644
index 00000000..b084869d
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleMaxBalanceOwnable2Step.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol | 10c0b8cde2773cc67a1c5e60448457c302753044 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleMaxBalanceOwnable2Step** | Implementation | RuleMaxBalanceBase, Ownable2Step, Ownable2StepERC165Module |||
+| └ | | Public ❗️ | 🛑 | RuleMaxBalanceBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMaxBalanceManager | Internal 🔒 | | onlyOwner |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleMaxTotalSupply.sol.md b/doc/surya/surya_report/surya_report_RuleMaxTotalSupply.sol.md
index e6e0d63d..5a81cc2f 100644
--- a/doc/surya/surya_report/surya_report_RuleMaxTotalSupply.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMaxTotalSupply.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleMaxTotalSupply.sol | 3880cdfe3de4b9ba850cb43e6310b2179329ce0d |
+| ./rules/validation/deployment/RuleMaxTotalSupply.sol | e6499a3861218ecfde7db68c440f0c2dd6690c3f |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyBase.sol.md b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyBase.sol.md
index 7c910e8b..450c634c 100644
--- a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol | c61ef04957bfd41edd3c13a799831f882d64813d |
+| ./rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol | 645be19fe00a1ab23bdd92491942d97cb31fa383 |
### Contracts Description Table
@@ -15,15 +15,12 @@
|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
-| **RuleMaxTotalSupplyBase** | Implementation | RuleTransferValidation, RuleMaxTotalSupplyInvariantStorage |||
+| **RuleMaxTotalSupplyBase** | Implementation | RuleTransferValidation, TotalSupplyCapManager |||
| └ | | Public ❗️ | 🛑 |NO❗️ |
| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
-| └ | setMaxTotalSupply | Public ❗️ | 🛑 | onlyMaxTotalSupplyManager |
-| └ | setTokenContract | Public ❗️ | 🛑 | onlyMaxTotalSupplyManager |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
-| └ | _authorizeMaxTotalSupplyManager | Internal 🔒 | | |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyInvariantStorage.sol.md
index 7e06b94a..b53c6da0 100644
--- a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol | bec7a27e5f4de88f379ab8440eb386ed0814773f |
+| ./rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol | 93b7eea006bdbecdf7fd9973fef5c2f8904ae9c1 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyOwnable2Step.sol.md
index 44457768..24147ca2 100644
--- a/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMaxTotalSupplyOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol | f41457c8a1aefbb09ff45cbee3083f9111525686 |
+| ./rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol | 900ebc7cc8497e40bfc50d800aa49213d33da3f6 |
### Contracts Description Table
@@ -17,8 +17,8 @@
||||||
| **RuleMaxTotalSupplyOwnable2Step** | Implementation | RuleMaxTotalSupplyBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleMaxTotalSupplyBase Ownable |
-| └ | _authorizeMaxTotalSupplyManager | Internal 🔒 | | onlyOwner |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMaxTotalSupplyManager | Internal 🔒 | | onlyOwner |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleMintAllowance.sol.md b/doc/surya/surya_report/surya_report_RuleMintAllowance.sol.md
index 9bcd9114..707e831d 100644
--- a/doc/surya/surya_report/surya_report_RuleMintAllowance.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMintAllowance.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleMintAllowance.sol | 20af9da8f7f75bbedd817de955e768f9a716b54c |
+| ./rules/operation/RuleMintAllowance.sol | e2cf3135bdb23ade767c610bd125d6d39706a617 |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleMintAllowance** | Implementation | AccessControlModuleStandalone, RuleMintAllowanceBase, ERC3643ComplianceRolesStorage |||
| └ | | Public ❗️ | 🛑 | AccessControlModuleStandalone |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyRole |
| └ | _authorizeSetMintAllowance | Internal 🔒 | | onlyRole |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyRole |
| └ | _authorizeComplianceBindingChange | Internal 🔒 | | onlyRole |
diff --git a/doc/surya/surya_report/surya_report_RuleMintAllowanceBase.sol.md b/doc/surya/surya_report/surya_report_RuleMintAllowanceBase.sol.md
index 530992f7..d8b8f731 100644
--- a/doc/surya/surya_report/surya_report_RuleMintAllowanceBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMintAllowanceBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleMintAllowanceBase.sol | 1676c17359978a8e901da2f022762462a44a7968 |
+| ./rules/operation/abstract/RuleMintAllowanceBase.sol | c702b5250dc5dea9ee0a34fb1644def4b6a1385b |
### Contracts Description Table
@@ -16,25 +16,26 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **RuleMintAllowanceBase** | Implementation | VersionModule, ERC3643ComplianceModule, RuleMintAllowanceInvariantStorage, IRule |||
-| └ | _authorizeSetMintAllowance | Internal 🔒 | | |
-| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
| └ | created | External ❗️ | 🛑 | onlyBoundToken |
| └ | destroyed | External ❗️ | 🛑 | onlyBoundToken |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
| └ | setMintAllowance | Public ❗️ | 🛑 | onlyAllowanceOperator |
| └ | increaseMintAllowance | Public ❗️ | 🛑 | onlyAllowanceOperator |
| └ | decreaseMintAllowance | Public ❗️ | 🛑 | onlyAllowanceOperator |
+| └ | clearMintAllowances | Public ❗️ | 🛑 | onlyAllowanceOperator |
| └ | bindToken | Public ❗️ | 🛑 | onlyComplianceManager |
-| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | 🛑 | onlyBoundToken |
| └ | transferred | Public ❗️ | 🛑 | onlyBoundToken |
+| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
| └ | detectTransferRestriction | Public ❗️ | |NO❗️ |
| └ | detectTransferRestrictionFrom | Public ❗️ | |NO❗️ |
| └ | canTransfer | Public ❗️ | |NO❗️ |
| └ | canTransferFrom | Public ❗️ | |NO❗️ |
-| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | 🛑 | |
| └ | _transferredFrom | Internal 🔒 | 🛑 | |
| └ | _setMintAllowance | Internal 🔒 | 🛑 | |
+| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
+| └ | _authorizeSetMintAllowance | Internal 🔒 | | |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleMintAllowanceInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleMintAllowanceInvariantStorage.sol.md
index 8f8e894e..2f332f52 100644
--- a/doc/surya/surya_report/surya_report_RuleMintAllowanceInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMintAllowanceInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol | 99b25d9540492defbc4168b3f07b47498a07f081 |
+| ./rules/operation/abstract/RuleMintAllowanceInvariantStorage.sol | 696a62157ce9b1b2da23d82edcc3f3bc018d2591 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleMintAllowanceOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleMintAllowanceOwnable2Step.sol.md
index 171f23ac..d15ab5d1 100644
--- a/doc/surya/surya_report/surya_report_RuleMintAllowanceOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleMintAllowanceOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/operation/RuleMintAllowanceOwnable2Step.sol | 32837216a0005192e2bfbe7a91ab7bd782a32238 |
+| ./rules/operation/RuleMintAllowanceOwnable2Step.sol | 47d79e70bd08f2719e55e4f27c356b494e25196f |
### Contracts Description Table
@@ -18,8 +18,8 @@
| **RuleMintAllowanceOwnable2Step** | Implementation | RuleMintAllowanceBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | Ownable |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _onlyComplianceManager | Internal 🔒 | | onlyOwner |
| └ | _authorizeSetMintAllowance | Internal 🔒 | | onlyOwner |
-| └ | _onlyComplianceManager | Internal 🔒 | 🛑 | onlyOwner |
| └ | _authorizeComplianceBindingChange | Internal 🔒 | | onlyOwner |
diff --git a/doc/surya/surya_report/surya_report_RuleNFTAdapter.sol.md b/doc/surya/surya_report/surya_report_RuleNFTAdapter.sol.md
index 3f3a1478..87cce742 100644
--- a/doc/surya/surya_report/surya_report_RuleNFTAdapter.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleNFTAdapter.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/core/RuleNFTAdapter.sol | b8f541d2d2891f8ef387c2830c86043dbc43ec19 |
+| ./rules/validation/abstract/core/RuleNFTAdapter.sol | 77a5fb86eed3006ca5020e7d6f223758c8b309a1 |
### Contracts Description Table
@@ -16,14 +16,14 @@
| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
||||||
| **RuleNFTAdapter** | Implementation | RuleTransferValidation, IERC7943NonFungibleComplianceExtend, ITransferContext |||
+| └ | transferred | External ❗️ | 🛑 |NO❗️ |
+| └ | transferred | External ❗️ | 🛑 |NO❗️ |
+| └ | transferred | Public ❗️ | 🛑 |NO❗️ |
+| └ | transferred | Public ❗️ | 🛑 |NO❗️ |
| └ | detectTransferRestriction | Public ❗️ | |NO❗️ |
| └ | detectTransferRestrictionFrom | Public ❗️ | |NO❗️ |
| └ | canTransfer | Public ❗️ | |NO❗️ |
| └ | canTransferFrom | Public ❗️ | |NO❗️ |
-| └ | transferred | Public ❗️ | 🛑 |NO❗️ |
-| └ | transferred | Public ❗️ | 🛑 |NO❗️ |
-| └ | transferred | External ❗️ | 🛑 |NO❗️ |
-| └ | transferred | External ❗️ | 🛑 |NO❗️ |
| └ | _transferred | Internal 🔒 | 🛑 | |
| └ | _transferredFrom | Internal 🔒 | 🛑 | |
diff --git a/doc/surya/surya_report/surya_report_RuleReceiverWhitelist.sol.md b/doc/surya/surya_report/surya_report_RuleReceiverWhitelist.sol.md
new file mode 100644
index 00000000..0b1c9adf
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleReceiverWhitelist.sol.md
@@ -0,0 +1,33 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleReceiverWhitelist.sol | 16ded97b659974c7d37bae7fbbf568b43f641456 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleReceiverWhitelist** | Implementation | RuleReceiverWhitelistBase, AccessControlModuleStandalone |||
+| └ | | Public ❗️ | 🛑 | RuleReceiverWhitelistBase AccessControlModuleStandalone |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyRole |
+| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyRole |
+| └ | _msgSender | Internal 🔒 | | |
+| └ | _msgData | Internal 🔒 | | |
+| └ | _contextSuffixLength | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleReceiverWhitelistBase.sol.md b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistBase.sol.md
new file mode 100644
index 00000000..2e2a285a
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistBase.sol.md
@@ -0,0 +1,36 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/base/RuleReceiverWhitelistBase.sol | d3b77283cf3de8426036be4d0a61e86ae0d1c3c8 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleReceiverWhitelistBase** | Implementation | RuleAddressSet, RuleNFTAdapter, RuleReceiverWhitelistInvariantStorage |||
+| └ | | Public ❗️ | 🛑 | RuleAddressSet |
+| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _detectTransferRestriction | Internal 🔒 | | |
+| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
+| └ | _transferred | Internal 🔒 | | |
+| └ | _transferredFrom | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleReceiverWhitelistHarnesses.sol.md b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistHarnesses.sol.md
new file mode 100644
index 00000000..5e2ea9a3
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistHarnesses.sol.md
@@ -0,0 +1,36 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/harness/RuleReceiverWhitelistHarnesses.sol | ec8b9e5451f50ceab5872d4360f7c60f19292691 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleReceiverWhitelistHarness** | Implementation | RuleReceiverWhitelist |||
+| └ | | Public ❗️ | 🛑 | RuleReceiverWhitelist |
+| └ | exposedMsgSender | External ❗️ | |NO❗️ |
+| └ | exposedMsgData | External ❗️ | |NO❗️ |
+| └ | exposedContextSuffixLength | External ❗️ | |NO❗️ |
+||||||
+| **RuleReceiverWhitelistOwnable2StepHarness** | Implementation | RuleReceiverWhitelistOwnable2Step |||
+| └ | | Public ❗️ | 🛑 | RuleReceiverWhitelistOwnable2Step |
+| └ | exposedMsgSender | External ❗️ | |NO❗️ |
+| └ | exposedMsgData | External ❗️ | |NO❗️ |
+| └ | exposedContextSuffixLength | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleReceiverWhitelistInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistInvariantStorage.sol.md
new file mode 100644
index 00000000..bbc1419b
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistInvariantStorage.sol.md
@@ -0,0 +1,26 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol | cccfaa473569d0687adb11ea2dd2df8726c6535d |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleReceiverWhitelistInvariantStorage** | Implementation | RuleSharedInvariantStorage |||
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleReceiverWhitelistOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistOwnable2Step.sol.md
new file mode 100644
index 00000000..8c3a0666
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_RuleReceiverWhitelistOwnable2Step.sol.md
@@ -0,0 +1,33 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol | 29366b64f3f25ee0da62966fc734443493aa4e39 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **RuleReceiverWhitelistOwnable2Step** | Implementation | RuleReceiverWhitelistBase, Ownable2Step, Ownable2StepERC165Module |||
+| └ | | Public ❗️ | 🛑 | RuleReceiverWhitelistBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyOwner |
+| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyOwner |
+| └ | _msgSender | Internal 🔒 | | |
+| └ | _msgData | Internal 🔒 | | |
+| └ | _contextSuffixLength | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_RuleSanctionsList.sol.md b/doc/surya/surya_report/surya_report_RuleSanctionsList.sol.md
index 8ebe7f25..32b4e9d2 100644
--- a/doc/surya/surya_report/surya_report_RuleSanctionsList.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSanctionsList.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleSanctionsList.sol | 8b9a8d5f53a59eccc4ac0204eeab3857cd8e18c4 |
+| ./rules/validation/deployment/RuleSanctionsList.sol | 8b9b2d1c1290e10fac6a77a44759bcadf7e456c5 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSanctionsListBase.sol.md b/doc/surya/surya_report/surya_report_RuleSanctionsListBase.sol.md
index 3a28118a..ead2fa78 100644
--- a/doc/surya/surya_report/surya_report_RuleSanctionsListBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSanctionsListBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleSanctionsListBase.sol | c2e3984467b78f44467fceaeb9773a6dbd98ba83 |
+| ./rules/validation/abstract/base/RuleSanctionsListBase.sol | f3cf16d7239ca40019b9790c503224470f440a63 |
### Contracts Description Table
@@ -23,12 +23,12 @@
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | _setSanctionListOracle | Internal 🔒 | 🛑 | |
| └ | _authorizeSanctionListManager | Internal 🔒 | | |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
| └ | _transferredFrom | Internal 🔒 | | |
-| └ | _setSanctionListOracle | Internal 🔒 | 🛑 | |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleSanctionsListInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleSanctionsListInvariantStorage.sol.md
index 7df45742..0b721f86 100644
--- a/doc/surya/surya_report/surya_report_RuleSanctionsListInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSanctionsListInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol | 3d359490f48dcb20ee80dd25fe05aa9d447283b4 |
+| ./rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol | 2bb0eb877259f6e97bed92a748bca1e11dc686bb |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2Step.sol.md
index e9cf3c0a..2d7b510e 100644
--- a/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleSanctionsListOwnable2Step.sol | 0885c8f3fd637c7131ce04cc5a736b9acb60c616 |
+| ./rules/validation/deployment/RuleSanctionsListOwnable2Step.sol | f1b7ef145efb592d5e51838eff2712c77dd1dc54 |
### Contracts Description Table
@@ -17,8 +17,8 @@
||||||
| **RuleSanctionsListOwnable2Step** | Implementation | RuleSanctionsListBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleSanctionsListBase Ownable |
-| └ | _authorizeSanctionListManager | Internal 🔒 | | onlyOwner |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeSanctionListManager | Internal 🔒 | | onlyOwner |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2StepHarness.sol.md b/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2StepHarness.sol.md
index 2cc62e1f..ef04c07b 100644
--- a/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2StepHarness.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSanctionsListOwnable2StepHarness.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/harness/RuleSanctionsListOwnable2StepHarness.sol | 4ef2243400087a7fe3e72d38fd5f2c2bb160ee1d |
+| ./mocks/harness/RuleSanctionsListOwnable2StepHarness.sol | 5e3aaeac93cd6c4c55b1f88c53026bb5f463e9ed |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSharedInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleSharedInvariantStorage.sol.md
index e446e6ca..04bf73ee 100644
--- a/doc/surya/surya_report/surya_report_RuleSharedInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSharedInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol | c64ab28b9789c1339609af1fde0cfd314976645d |
+| ./rules/validation/abstract/invariant/RuleSharedInvariantStorage.sol | 818c19d2ed7d6fd8d3d91ac2157958ae9bc2c48d |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSpenderWhitelist.sol.md b/doc/surya/surya_report/surya_report_RuleSpenderWhitelist.sol.md
index c7b1288c..615afe28 100644
--- a/doc/surya/surya_report/surya_report_RuleSpenderWhitelist.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSpenderWhitelist.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleSpenderWhitelist.sol | fd6fa68104586756ba54f27a4ba950be852368ef |
+| ./rules/validation/deployment/RuleSpenderWhitelist.sol | bf6f1310bd74298015b55caec90f9291290cf722 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistBase.sol.md b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistBase.sol.md
index 84c327dd..ce8c5c5a 100644
--- a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleSpenderWhitelistBase.sol | 6a5f291ea67c01dc192a26ca6e51e0034aca9621 |
+| ./rules/validation/abstract/base/RuleSpenderWhitelistBase.sol | f98bec5c483d2dc831f4b751f4a4d72a48cddf84 |
### Contracts Description Table
@@ -21,6 +21,7 @@
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | messageForTransferRestriction | Public ❗️ | |NO❗️ |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistHarnesses.sol.md b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistHarnesses.sol.md
index 2ec69c4f..9ac21724 100644
--- a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistHarnesses.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistHarnesses.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/harness/RuleSpenderWhitelistHarnesses.sol | 2a35f957537cc77ebb9d88c1270c0c4a0ab6ab6d |
+| ./mocks/harness/RuleSpenderWhitelistHarnesses.sol | a2055cabf89ea597955f025d7a4444fd47ad3deb |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistInvariantStorage.sol.md
index baf1ff2b..1ae4908f 100644
--- a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol | 94c54ff0f3e4162cb50e082ea6a23df02cf0ddb5 |
+| ./rules/validation/abstract/invariant/RuleSpenderWhitelistInvariantStorage.sol | a86a920811cf909311a37a9ef2112bb46f1e0c8d |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistOwnable2Step.sol.md
index 5006e911..d7c93ed9 100644
--- a/doc/surya/surya_report/surya_report_RuleSpenderWhitelistOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleSpenderWhitelistOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol | f45ba40d563a6f073f666cd1cd3866efab959081 |
+| ./rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol | b2584e146b1d5de373a2052be54d210e0a15f03b |
### Contracts Description Table
@@ -17,9 +17,9 @@
||||||
| **RuleSpenderWhitelistOwnable2Step** | Implementation | RuleSpenderWhitelistBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleSpenderWhitelistBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyOwner |
| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyOwner |
-| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleTransferValidation.sol.md b/doc/surya/surya_report/surya_report_RuleTransferValidation.sol.md
index db498963..58d73d5c 100644
--- a/doc/surya/surya_report/surya_report_RuleTransferValidation.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleTransferValidation.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/core/RuleTransferValidation.sol | 2506b3a85578946fbff945a869252061a98672b2 |
+| ./rules/validation/abstract/core/RuleTransferValidation.sol | 8b7882c2f071b0a6480e27f3ecc1e5dbc6eaab4a |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelist.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelist.sol.md
index f31100ad..fc691826 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelist.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelist.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleWhitelist.sol | 28719efcbd53f0d16152ede604d78ed0a05a7ae6 |
+| ./rules/validation/deployment/RuleWhitelist.sol | 16c2901fb3caec37a30449ad7e3009dc371b536a |
### Contracts Description Table
@@ -19,6 +19,7 @@
| └ | | Public ❗️ | 🛑 | RuleWhitelistBase AccessControlModuleStandalone |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _authorizeCheckSpenderManager | Internal 🔒 | | onlyRole |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyRole |
| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyRole |
| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyRole |
| └ | _msgSender | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistBase.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistBase.sol.md
index af2048ce..05bc0295 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleWhitelistBase.sol | c03f8b0e8af1c93b60cbde46d512b75eb501c805 |
+| ./rules/validation/abstract/base/RuleWhitelistBase.sol | 7cb62bf29323cbf092a1f0787d6b7cf6e929d41b |
### Contracts Description Table
@@ -17,13 +17,10 @@
||||||
| **RuleWhitelistBase** | Implementation | RuleAddressSet, RuleWhitelistShared, IIdentityRegistryVerified |||
| └ | | Public ❗️ | 🛑 | RuleAddressSet |
-| └ | setCheckSpender | Public ❗️ | 🛑 | onlyCheckSpenderManager |
| └ | isVerified | Public ❗️ | |NO❗️ |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
-| └ | _authorizeCheckSpenderManager | Internal 🔒 | | |
| └ | _detectTransferRestriction | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
-| └ | _setCheckSpender | Internal 🔒 | 🛑 | |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistInvariantStorage.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistInvariantStorage.sol.md
index a246ab57..5de6dae4 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistInvariantStorage.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistInvariantStorage.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol | 6c2af6b4853b757d90cd675e7c9d3ed90522959f |
+| ./rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol | 15e5c48a6b853a46f25131198e0415fe0ff91524 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistOwnable2Step.sol.md
index 0c142c38..f6b19fd6 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleWhitelistOwnable2Step.sol | 6c451cec10719b78b486fff1254daaf78d3c3a25 |
+| ./rules/validation/deployment/RuleWhitelistOwnable2Step.sol | 9db048dbd8c9d24207d0a87607ba7401252bf42f |
### Contracts Description Table
@@ -17,10 +17,11 @@
||||||
| **RuleWhitelistOwnable2Step** | Implementation | RuleWhitelistBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleWhitelistBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _authorizeAddressListAdd | Internal 🔒 | | onlyOwner |
| └ | _authorizeAddressListRemove | Internal 🔒 | | onlyOwner |
| └ | _authorizeCheckSpenderManager | Internal 🔒 | | onlyOwner |
-| └ | supportsInterface | Public ❗️ | |NO❗️ |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyOwner |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistShared.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistShared.sol.md
index 25d1139f..59ac1c23 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistShared.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistShared.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/core/RuleWhitelistShared.sol | b65096ed397e2a1c3506236a481824da43a4de35 |
+| ./rules/validation/abstract/core/RuleWhitelistShared.sol | 051cbbbba471e37946c2d7ea8c65c8219c317cf4 |
### Contracts Description Table
@@ -18,8 +18,16 @@
| **RuleWhitelistShared** | Implementation | RuleNFTAdapter, RuleWhitelistInvariantStorage |||
| └ | canReturnTransferRestrictionCode | External ❗️ | |NO❗️ |
| └ | messageForTransferRestriction | External ❗️ | |NO❗️ |
+| └ | setCheckSpender | Public ❗️ | 🛑 | onlyCheckSpenderManager |
+| └ | setAllowMint | Public ❗️ | 🛑 | onlyMintBurnManager |
+| └ | setAllowBurn | Public ❗️ | 🛑 | onlyMintBurnManager |
| └ | transferred | Public ❗️ | |NO❗️ |
| └ | transferred | Public ❗️ | |NO❗️ |
+| └ | _setCheckSpender | Internal 🔒 | 🛑 | |
+| └ | _setAllowMintBurn | Internal 🔒 | 🛑 | |
+| └ | _detectMintBurnRestriction | Internal 🔒 | | |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | |
+| └ | _authorizeCheckSpenderManager | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
| └ | _transferredFrom | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistWrapper.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistWrapper.sol.md
index f69c8760..42c7a8d5 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistWrapper.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistWrapper.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleWhitelistWrapper.sol | a2aa47367533733b60d7aca73921f7e3546424b7 |
+| ./rules/validation/deployment/RuleWhitelistWrapper.sol | 79bfa83d45328a85499649a3feee38d40993f567 |
### Contracts Description Table
@@ -19,14 +19,15 @@
| └ | | Public ❗️ | 🛑 | RuleWhitelistWrapperBase AccessControlModuleStandalone |
| └ | hasRole | Public ❗️ | |NO❗️ |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
-| └ | _authorizeCheckSpenderManager | Internal 🔒 | 🛑 | onlyRole |
-| └ | _onlyRulesManager | Internal 🔒 | 🛑 | onlyRole |
-| └ | _onlyRulesLimitManager | Internal 🔒 | 🛑 | onlyRole |
+| └ | _grantRole | Internal 🔒 | 🛑 | |
+| └ | _revokeRole | Internal 🔒 | 🛑 | |
+| └ | _authorizeCheckSpenderManager | Internal 🔒 | | onlyRole |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyRole |
+| └ | _onlyRulesManager | Internal 🔒 | | onlyRole |
+| └ | _onlyRulesLimitManager | Internal 🔒 | | onlyRole |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
-| └ | _grantRole | Internal 🔒 | 🛑 | |
-| └ | _revokeRole | Internal 🔒 | 🛑 | |
### Legend
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperBase.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperBase.sol.md
index ddbc88ab..d96bc2a5 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperBase.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperBase.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/abstract/base/RuleWhitelistWrapperBase.sol | f345ef26fdc628576e5f4497642bd782d5f6d709 |
+| ./rules/validation/abstract/base/RuleWhitelistWrapperBase.sol | bb3b6454d4316c7a7bcc82e6e599ffc45bacdd7a |
### Contracts Description Table
@@ -17,16 +17,14 @@
||||||
| **RuleWhitelistWrapperBase** | Implementation | RulesManagementModule, MetaTxModuleStandalone, RuleWhitelistShared, IIdentityRegistryVerified |||
| └ | | Public ❗️ | 🛑 | MetaTxModuleStandalone |
-| └ | _authorizeCheckSpenderManager | Internal 🔒 | 🛑 | |
-| └ | setCheckSpender | Public ❗️ | 🛑 | onlyCheckSpenderManager |
| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | isVerified | Public ❗️ | |NO❗️ |
| └ | _detectTransferRestriction | Internal 🔒 | | |
+| └ | _isListedInAnyChild | Internal 🔒 | | |
| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
| └ | _transferred | Internal 🔒 | | |
| └ | _detectTransferRestrictionForTargets | Internal 🔒 | | |
-| └ | _setCheckSpender | Internal 🔒 | 🛑 | |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperHarnessInternal.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperHarnessInternal.sol.md
index 9cc84d07..10a03b09 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperHarnessInternal.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperHarnessInternal.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/harness/RuleWhitelistWrapperHarnessInternal.sol | 3d8369bf0e3f3ad66c4732df955b00491a2d83c4 |
+| ./mocks/harness/RuleWhitelistWrapperHarnessInternal.sol | 2d232d74651ec4ca83a106e64c5228d87adcb959 |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperOwnable2Step.sol.md b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperOwnable2Step.sol.md
index dbd41447..1918ac4d 100644
--- a/doc/surya/surya_report/surya_report_RuleWhitelistWrapperOwnable2Step.sol.md
+++ b/doc/surya/surya_report/surya_report_RuleWhitelistWrapperOwnable2Step.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol | daca724ea3684221c6205d7c4784c48e5ed73074 |
+| ./rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol | b57ea6f0e72d5914fe47d96579a310a4b47d634f |
### Contracts Description Table
@@ -17,10 +17,11 @@
||||||
| **RuleWhitelistWrapperOwnable2Step** | Implementation | RuleWhitelistWrapperBase, Ownable2Step, Ownable2StepERC165Module |||
| └ | | Public ❗️ | 🛑 | RuleWhitelistWrapperBase Ownable |
+| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _authorizeCheckSpenderManager | Internal 🔒 | | onlyOwner |
+| └ | _authorizeMintBurnManager | Internal 🔒 | | onlyOwner |
| └ | _onlyRulesManager | Internal 🔒 | | onlyOwner |
| └ | _onlyRulesLimitManager | Internal 🔒 | | onlyOwner |
-| └ | supportsInterface | Public ❗️ | |NO❗️ |
| └ | _msgSender | Internal 🔒 | | |
| └ | _msgData | Internal 🔒 | | |
| └ | _contextSuffixLength | Internal 🔒 | | |
diff --git a/doc/surya/surya_report/surya_report_SanctionListOracle.sol.md b/doc/surya/surya_report/surya_report_SanctionListOracle.sol.md
index e08c8c8d..ec6a7498 100644
--- a/doc/surya/surya_report/surya_report_SanctionListOracle.sol.md
+++ b/doc/surya/surya_report/surya_report_SanctionListOracle.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/SanctionListOracle.sol | 01c0c904f32a647b4e851d03b6ca2947ed119bd0 |
+| ./mocks/SanctionListOracle.sol | a680799215477f8865d8f39982edf293cd24f3dc |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_SanctionsListDelegationHarness.sol.md b/doc/surya/surya_report/surya_report_SanctionsListDelegationHarness.sol.md
new file mode 100644
index 00000000..a9398a50
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_SanctionsListDelegationHarness.sol.md
@@ -0,0 +1,28 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/harness/SanctionsListDelegationHarness.sol | d44cf59a92ad9c1bf119269b233690ef67ceff55 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **SanctionsListExtraCheckHarness** | Implementation | RuleSanctionsList |||
+| └ | | Public ❗️ | 🛑 | RuleSanctionsList |
+| └ | _detectTransferRestriction | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_TokenSupplyReader.sol.md b/doc/surya/surya_report/surya_report_TokenSupplyReader.sol.md
new file mode 100644
index 00000000..52bfbde4
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_TokenSupplyReader.sol.md
@@ -0,0 +1,29 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/core/TokenSupplyReader.sol | d7faee1c8cfbc1c31fb97f65823c1c4648b3d793 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **TokenSupplyReader** | Implementation | |||
+| └ | _supplyToken | Internal 🔒 | | |
+| └ | _currentSupply | Internal 🔒 | | |
+| └ | _probeTotalSupplyCallable | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_TotalSupplyCapManager.sol.md b/doc/surya/surya_report/surya_report_TotalSupplyCapManager.sol.md
new file mode 100644
index 00000000..6984c286
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_TotalSupplyCapManager.sol.md
@@ -0,0 +1,34 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./rules/validation/abstract/core/TotalSupplyCapManager.sol | 2ccec17348c8be7669d4241186fb870cc4069d8a |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **TotalSupplyCapManager** | Implementation | TokenSupplyReader, RuleMaxTotalSupplyInvariantStorage |||
+| └ | setMaxTotalSupply | Public ❗️ | 🛑 | onlyMaxTotalSupplyManager |
+| └ | setTokenContract | Public ❗️ | 🛑 | onlyMaxTotalSupplyManager |
+| └ | _setMaxTotalSupply | Internal 🔒 | 🛑 | |
+| └ | _setTokenContract | Internal 🔒 | 🛑 | |
+| └ | _validateTokenContract | Internal 🔒 | | |
+| └ | _authorizeMaxTotalSupplyManager | Internal 🔒 | | |
+| └ | _supplyToken | Internal 🔒 | | |
+| └ | _capExceeded | Internal 🔒 | | |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_TotalSupplyDecimalsMock.sol.md b/doc/surya/surya_report/surya_report_TotalSupplyDecimalsMock.sol.md
new file mode 100644
index 00000000..fca1009f
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_TotalSupplyDecimalsMock.sol.md
@@ -0,0 +1,31 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/TotalSupplyDecimalsMock.sol | 6c0237d590bda395af1c06b70c932c1eff4773f1 |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **TotalSupplyDecimalsMock** | Implementation | |||
+| └ | | Public ❗️ | 🛑 |NO❗️ |
+| └ | setTotalSupply | External ❗️ | 🛑 |NO❗️ |
+| └ | setRevertOnTotalSupply | External ❗️ | 🛑 |NO❗️ |
+| └ | totalSupply | External ❗️ | |NO❗️ |
+| └ | decimals | External ❗️ | |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/surya/surya_report/surya_report_TotalSupplyMock.sol.md b/doc/surya/surya_report/surya_report_TotalSupplyMock.sol.md
index 9892948e..53818339 100644
--- a/doc/surya/surya_report/surya_report_TotalSupplyMock.sol.md
+++ b/doc/surya/surya_report/surya_report_TotalSupplyMock.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./mocks/TotalSupplyMock.sol | 6c950ae26666c8693a87d9c66ea2a87107d03dd1 |
+| ./mocks/TotalSupplyMock.sol | 8d4a76dbfceac5e628416fa5989c86050062059c |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_VersionModule.sol.md b/doc/surya/surya_report/surya_report_VersionModule.sol.md
index 0cf68797..aeebbe66 100644
--- a/doc/surya/surya_report/surya_report_VersionModule.sol.md
+++ b/doc/surya/surya_report/surya_report_VersionModule.sol.md
@@ -5,7 +5,7 @@
| File Name | SHA-1 Hash |
|-------------|--------------|
-| ./modules/VersionModule.sol | 3351ae4920df4e78c4551e06c79154d8f8b70814 |
+| ./modules/VersionModule.sol | 41780d1380a0071906b292cf236c8b07f81d941d |
### Contracts Description Table
diff --git a/doc/surya/surya_report/surya_report_VirtualHookOverrideHarnesses.sol.md b/doc/surya/surya_report/surya_report_VirtualHookOverrideHarnesses.sol.md
new file mode 100644
index 00000000..88559912
--- /dev/null
+++ b/doc/surya/surya_report/surya_report_VirtualHookOverrideHarnesses.sol.md
@@ -0,0 +1,50 @@
+## Sūrya's Description Report
+
+### Files Description Table
+
+
+| File Name | SHA-1 Hash |
+|-------------|--------------|
+| ./mocks/harness/VirtualHookOverrideHarnesses.sol | 6854c56840f080b9c00452015062e74992314b8b |
+
+
+### Contracts Description Table
+
+
+| Contract | Type | Bases | | |
+|:----------:|:-------------------:|:----------------:|:----------------:|:---------------:|
+| └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** |
+||||||
+| **ConditionalTransferLightCustomExecutorHarness** | Implementation | RuleConditionalTransferLight |||
+| └ | | Public ❗️ | 🛑 | RuleConditionalTransferLight |
+| └ | _authorizeTransferExecution | Internal 🔒 | | |
+| └ | approveTransfer | Public ❗️ | 🛑 |NO❗️ |
+| └ | transferred | Public ❗️ | 🛑 |NO❗️ |
+||||||
+| **MaxTotalSupplyCappedSetterHarness** | Implementation | RuleMaxTotalSupply |||
+| └ | | Public ❗️ | 🛑 | RuleMaxTotalSupply |
+| └ | setMaxTotalSupply | Public ❗️ | 🛑 |NO❗️ |
+||||||
+| **IdentityRegistryPinnedHarness** | Implementation | RuleIdentityRegistry |||
+| └ | | Public ❗️ | 🛑 | RuleIdentityRegistry |
+| └ | setIdentityRegistry | Public ❗️ | 🛑 |NO❗️ |
+||||||
+| **ERC2980SelfWhitelistBlockHarness** | Implementation | RuleERC2980 |||
+| └ | | Public ❗️ | 🛑 | RuleERC2980 |
+| └ | addWhitelistAddress | Public ❗️ | 🛑 |NO❗️ |
+||||||
+| **BlacklistQuarantineHarness** | Implementation | RuleBlacklist |||
+| └ | | Public ❗️ | 🛑 | RuleBlacklist |
+| └ | _detectTransferRestriction | Internal 🔒 | | |
+| └ | _detectTransferRestrictionFrom | Internal 🔒 | | |
+| └ | canTransfer | Public ❗️ | |NO❗️ |
+| └ | canTransfer | Public ❗️ | |NO❗️ |
+| └ | addAddress | Public ❗️ | 🛑 |NO❗️ |
+
+
+### Legend
+
+| Symbol | Meaning |
+|:--------:|-----------|
+| 🛑 | Function can modify state |
+| 💵 | Function is payable |
diff --git a/doc/technical/RuleMaxTotalSupply.md b/doc/technical/RuleMaxTotalSupply.md
deleted file mode 100644
index 0cc5603a..00000000
--- a/doc/technical/RuleMaxTotalSupply.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# Rule Max Total Supply
-
-[TOC]
-
-This rule restricts minting so that the token's total supply never exceeds a configured maximum. Only mint operations (`from == address(0)`) are checked. Regular transfers between holders and burns are not affected.
-
-## Configuration
-
-### Constructor parameters
-
-| Parameter | Description |
-| --- | --- |
-| `admin` | Address granted `DEFAULT_ADMIN_ROLE` (implicitly holds all roles) |
-| `tokenContract_` | Address of the token contract (must implement `totalSupply()`); must be non-zero |
-| `maxTotalSupply_` | Initial maximum total supply cap |
-
-### Post-deployment configuration
-
-Both the cap and the token contract address can be updated by the admin after deployment.
-
-## Schema
-
-### Graph
-
-
-
-### Inheritance
-
-
-
-### Flow with a CMTAT token
-
-The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a mint. Only mints (`from == address(0)`) are gated; transfers and burns pass.
-
-
-
-_Diagram source: doc/img/rule-max-total-supply-flow.puml._
-
-## Restriction codes
-
-| Constant | Code | Meaning |
-| --- | --- | --- |
-| `CODE_MAX_TOTAL_SUPPLY_EXCEEDED` | 50 | Mint would cause total supply to exceed the maximum |
-
-## Access Control
-
-The default admin is the address passed as `admin` in the constructor. It is granted `DEFAULT_ADMIN_ROLE`, which implicitly holds all roles. All privileged operations are gated on `DEFAULT_ADMIN_ROLE`.
-
-| Role | Description |
-| --- | --- |
-| `DEFAULT_ADMIN_ROLE` | May update the supply cap and token contract address |
-
-
-## Methods
-
-### `setMaxTotalSupply(uint256 newMaxTotalSupply)`
-
-Updates the maximum total supply cap. Restricted to `DEFAULT_ADMIN_ROLE`. Emits `MaxTotalSupplyUpdated`.
-
-### `setTokenContract(address newTokenContract)`
-
-Updates the reference to the token contract. Reverts if the address is zero. Restricted to `DEFAULT_ADMIN_ROLE`. Emits `TokenContractUpdated`.
-
-### `maxTotalSupply() → uint256`
-
-Returns the current maximum total supply.
-
-### `tokenContract() → ITotalSupply`
-
-Returns the current token contract address.
-
-## Transfer restriction logic
-
-The rule only acts on mint operations (i.e. `from == address(0)`). It reads `tokenContract.totalSupply()` and rejects the mint if `totalSupply + value > maxTotalSupply`. Transfers and burns always pass.
-
-## Usage scenario
-
-The operator deploys `RuleMaxTotalSupply` with `tokenContract = CMTAT_address` and `maxTotalSupply = 1_000_000`. The rule is registered in the `RuleEngine`. When the issuer mints 100,000 tokens and total supply is already 950,000, the mint is rejected with code 50. Transfers between existing holders continue unaffected.
diff --git a/doc/technical/RuleSanctionList.md b/doc/technical/RuleSanctionList.md
deleted file mode 100644
index 20f79955..00000000
--- a/doc/technical/RuleSanctionList.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# Rule SanctionsList
-
-[TOC]
-
-This rule uses the [Chainalysis](https://www.chainalysis.com/) on-chain oracle to block transfers involving sanctioned addresses. It checks the US, EU, and UN sanctions lists maintained by the oracle.
-
-## How to use
-
-Deploy the contract pointing to the Chainalysis oracle address. If either the sender (`from`), recipient (`to`), or spender (in `transferFrom`) is flagged by the oracle, the transfer is rejected.
-
-The oracle address and documentation are available here: [Chainalysis oracle for sanctions screening](https://go.chainalysis.com/chainalysis-oracle-docs.html).
-
-The oracle can be updated with `setSanctionListOracle` or disabled with `clearSanctionListOracle`. When no oracle is set (`address(0)`), all transfers pass this rule.
-
-## Schema
-
-### Graph
-
-
-
-### Inheritance
-
-
-
-### Flow with a CMTAT token
-
-The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer, including the Chainalysis oracle lookup and the no-oracle pass-through case.
-
-
-
-_Diagram source: doc/img/rule-sanctionslist-flow.puml._
-
-## Restriction codes
-
-| Constant | Code | Meaning |
-| --- | --- | --- |
-| `CODE_ADDRESS_FROM_IS_SANCTIONED` | 30 | Sender is sanctioned |
-| `CODE_ADDRESS_TO_IS_SANCTIONED` | 31 | Recipient is sanctioned |
-| `CODE_ADDRESS_SPENDER_IS_SANCTIONED` | 32 | Spender is sanctioned |
-
-## Access Control
-
-The default admin is the address passed as `admin` in the constructor. It is granted `DEFAULT_ADMIN_ROLE`, which implicitly holds all roles.
-
-| Role | Description |
-| --- | --- |
-| `DEFAULT_ADMIN_ROLE` | Manages all roles; can call all privileged functions |
-| `SANCTIONLIST_ROLE` | May update or clear the oracle address (`setSanctionListOracle`, `clearSanctionListOracle`) |
-
-
-## Methods
-
-### `setSanctionListOracle(ISanctionsList sanctionContractOracle_)`
-
-Sets the Chainalysis oracle contract. Reverts if the address is zero. Restricted to `SANCTIONLIST_ROLE`.
-
-### `clearSanctionListOracle()`
-
-Removes the oracle (sets it to `address(0)`), effectively disabling sanctions checks. Restricted to `SANCTIONLIST_ROLE`.
-
-### `sanctionsList() → ISanctionsList`
-
-Returns the current oracle address. Returns `address(0)` if no oracle is set.
-
-## Usage scenario
-
-The operator deploys `RuleSanctionsList` with the Chainalysis oracle address and registers it in the `RuleEngine`. When the CMTAT token triggers a transfer, the rule calls `isSanctioned(from)` and `isSanctioned(to)` on the oracle. If either returns `true`, the transfer is rejected. The operator can later point to an updated oracle by calling `setSanctionListOracle`.
diff --git a/doc/technical/contracts/IdentityRegistryWhitelist.md b/doc/technical/contracts/IdentityRegistryWhitelist.md
new file mode 100644
index 00000000..371ef8bf
--- /dev/null
+++ b/doc/technical/contracts/IdentityRegistryWhitelist.md
@@ -0,0 +1,190 @@
+# Identity Registry Whitelist (ERC-3643)
+
+[TOC]
+
+`IdentityRegistryWhitelist` is a whitelist that presents itself to an **ERC-3643 token as its identity registry**. Install it with `token.setIdentityRegistry(address(this))`. `registerIdentity` whitelists a wallet, `deleteIdentity` removes it, and `isVerified` answers the question the token asks on every inbound transfer.
+
+> **This is not a compliance rule.** It implements no `IRule` surface, has no restriction codes, and **must not be added to a `RuleEngine`**. It sits on the token's *identity registry* slot, not its *compliance* slot. Do not confuse it with [`RuleIdentityRegistry`](./RuleIdentityRegistry.md), which is the mirror image: a compliance rule that *consults* an external identity registry. This contract *is* the registry.
+
+> **Using it with a CMTAT token.** CMTAT has **no** `setIdentityRegistry` slot — that is ERC-3643 only — so
+> this contract cannot be installed on a CMTAT token directly. Reach it through
+> [`RuleIdentityRegistry`](./RuleIdentityRegistry.md) in a `RuleEngine`, which consults this registry over
+> `isVerified`. The two are wired by interface, not inheritance, and that pairing is pinned by
+> [`CMTATRuleIdentityRegistryComposition.t.sol`](../../../test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol).
+
+### Where the whitelist comes from
+
+The address set is **not re-implemented**. The contract inherits `RuleAddressSetInternal`, the same `EnumerableSet` machinery `RuleWhitelist` and `RuleBlacklist` are built on — so the storage layout, the zero-address guard and the revert errors (`RuleAddressSet_ZeroAddressNotAllowed`, `RuleAddressSet_AddressNotFound`) are shared code rather than a second implementation. **No separate whitelist contract is deployed**: the registry *is* the list.
+
+Only the `internal` layer is inherited, and that is deliberate: the registry exposes exactly one write API (the ERC-3643 one) rather than two overlapping ones. The two roles that gate `RuleAddressSet`'s public `addAddress` / `removeAddress` live in a separate `RuleAddressSetRolesStorage`, inherited by that public layer only, so **this registry does not advertise `ADDRESS_LIST_ADD_ROLE` / `ADDRESS_LIST_REMOVE_ROLE` at all** — it never enforces them, and exposing an inert role invites an operator to grant a privilege that authorises nothing. `testDoesNotExposeInertAddressListRoles` pins their absence from the ABI. Inheriting the public `RuleAddressSet` surface would add `addAddress` / `removeAddress` alongside `registerIdentity` / `deleteIdentity`, giving the same state change two sets of roles and two sets of events, and leaving an operator to guess which pair is authoritative.
+
+The design goal is a **wrapper, not a registry**: it adapts the calls an ERC-3643 token makes onto a plain whitelist, and keeps **no identity state whatsoever**: no ONCHAINID, no country, no claims. `registerIdentity`'s `_identity` and `_country` arguments exist so the ERC-3643 signature matches; both are discarded. Verification means exactly one thing: is this wallet on the whitelist.
+
+## Which ERC-3643 functions call the registry, and how
+
+Transcribed from the reference `Token.sol` (vendored at `lib/ERC-3643/contracts/token/Token.sol`).
+
+| Token function | Registry call | When | Effect if it returns false / reverts |
+| --- | --- | --- | --- |
+| `transfer(to, amount)` | `isVerified(_to)` | Before moving value | Reverts `"Transfer not possible"` |
+| `transferFrom(from, to, amount)` | `isVerified(_to)` | Before moving value | Reverts `"Transfer not possible"` |
+| `forcedTransfer(from, to, amount)` | `isVerified(_to)` | Before moving value | Reverts `"Transfer not possible"` |
+| `mint(to, amount)` | `isVerified(_to)` | Before minting | Reverts `"Identity is not verified."` |
+| `burn(user, amount)` | **none** | — | Burn never consults the registry |
+| `recoveryAddress(lost, new, onchainID)` | `investorCountry(lost)`, `registerIdentity(new, …)`, `deleteIdentity(lost)` | See sequence below | Reverts `"Recovery not possible"` if the supplied ONCHAINID does not vouch for the wallet |
+
+Every one of those calls is answered from the whitelist. `investorCountry` is the only one with nothing to answer from, and it returns a constant 0. `recoveryAddress` also calls `keyHasPurpose`, but **not on the registry** — see below.
+
+Two consequences worth internalising:
+
+- **Only the RECEIVER is ever screened.** ERC-3643 checks `_to`, never `_from` and never the spender. A de-listed holder can still send, which is deliberate: it lets a lapsed investor exit their position rather than being trapped. `forcedTransfer` bypasses freezes but **not** this check.
+- **`burn` bypasses the registry entirely**, so an issuer can always burn a de-listed holder out.
+
+### The `recoveryAddress` sequence
+
+```
+1. keyHasPurpose(keccak256(abi.encode(newWallet)), 1) ── on the CALLER-SUPPLIED onchainID
+ └─ false ⇒ revert "Recovery not possible" ── NOT the registry: see below
+2. investorCountry(lostWallet) ── always 0 here; discarded in step 3
+3. registerIdentity(newWallet, onchainID, country) ── called BY THE TOKEN; new wallet must
+ NOT already be registered
+4. forcedTransfer(lostWallet, newWallet, balance) ── re-enters isVerified(newWallet)
+5. deleteIdentity(lostWallet) ── called BY THE TOKEN
+```
+
+**Step 1 does not involve this registry.** Supply the investor's ONCHAINID, or any ERC-734 contract, as `_investorOnchainID`.
+
+An earlier revision implemented `keyHasPurpose` here so the registry could be passed as that argument, removing the ONCHAINID dependency entirely. It was **removed**, because it bought nothing: `Token.recoveryAddress` calls `keyHasPurpose` on the address the agent supplies and never cross-checks it against the registry (`Token.sol:303-305`), so an agent who wants to skip the gate simply passes a different contract. It was convenience for an honest agent, not a control — and it cost a reverse index plus two behavioural divergences from the reference registry, both of which are now gone.
+
+Steps 3 and 5 mean **the token itself is a caller of the registry's write functions**, which drives the access-control setup below.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+## Installation
+
+```solidity
+IdentityRegistryWhitelist registry = new IdentityRegistryWhitelist(admin);
+token.setIdentityRegistry(address(registry));
+
+bytes32 role = registry.IDENTITY_REGISTRAR_ROLE();
+registry.grantRole(role, operator); // maintains the whitelist
+registry.grantRole(role, address(token)); // REQUIRED for recoveryAddress
+```
+
+Then, to recover a wallet (the replacement wallet is registered **by the token**, so do not pre-register it):
+
+```solidity
+token.recoveryAddress(lostWallet, newWallet, investorOnchainId); // a real ERC-734 identity
+```
+
+## Access Control
+
+| Role | Description |
+| --- | --- |
+| `DEFAULT_ADMIN_ROLE` | Manages roles; implicitly holds every role |
+| `IDENTITY_REGISTRAR_ROLE` | May call `registerIdentity` and `deleteIdentity` |
+
+**The token must hold `IDENTITY_REGISTRAR_ROLE`**, because `recoveryAddress` makes the token call `registerIdentity` and `deleteIdentity`. Without it, every recovery reverts.
+
+> **Warning.** Granting the role to the token means the token contract can whitelist and de-whitelist arbitrary addresses. That is inherent to ERC-3643's recovery design — the reference `IdentityRegistry` requires the token to be an `agent` for exactly the same reason — but it does widen the trust boundary: the token's agents transitively control the whitelist. Grant the role to the token only once, and treat the token's agent set as part of the registry's trust model.
+
+## Methods
+
+### `registerIdentity(address _userAddress, address _identity, uint16 _country)`
+
+Adds a wallet to the whitelist. `_identity` is echoed in `IdentityRegistered` for off-chain traceability; `_country` is ignored entirely. **Neither is stored.** Reverts on the zero address. Restricted to `IDENTITY_REGISTRAR_ROLE`.
+
+Reverts on an already-registered wallet, matching the reference registry's `"address stored already"`.
+
+### `deleteIdentity(address _userAddress)`
+
+Removes a wallet from the whitelist and clears its reverse-index entry. Reverts if the wallet is not registered. Restricted to `IDENTITY_REGISTRAR_ROLE`. Emits `IdentityRemoved`.
+
+### `isVerified(address _userAddress) → bool`
+
+Whitelist membership. **`isVerified(address(0))` is always `false`**: the zero address can never enter the registry, so the registry can never authorise a mint to it.
+
+### `investorCountry(address _userAddress) → uint16`
+
+**Always returns 0.** No country is stored. The function exists only because `recoveryAddress` calls it (omitting it would make every recovery revert), and the 0 it returns is handed straight back into `registerIdentity`, which ignores it. See [Limitation 3](#3-no-identity-data-is-kept).
+
+### `version() → string`
+
+Returns the library release the contract was deployed from (currently `"0.5.0"`), via `VersionModule`. The
+registry is not a rule, but it is a deployable production contract wired into a token's identity slot, so being
+able to identify its release matters for the same reasons it does for the rules.
+
+### `registeredIdentityCount() → uint256`
+
+How many wallets are registered. There is deliberately no full enumeration getter, matching `RuleWhitelist` and `RuleBlacklist`, which expose a count but not the member list.
+
+## Limitations
+
+### 1. No ERC-734 surface: `recoveryAddress` needs a real ONCHAINID
+
+The registry implements no ERC-734 function, so `recoveryAddress` must be given an actual identity contract as `_investorOnchainID`. A deployment that has no ONCHAINID infrastructure must supply some ERC-734-compatible contract for recovery, or forgo `recoveryAddress` entirely — `forcedTransfer` plus a manual `registerIdentity` / `deleteIdentity` pair achieves the same end state under registrar control.
+
+This is a deliberate narrowing. Answering `keyHasPurpose` from the whitelist was implemented and then removed: it added no security (the agent chooses which contract is called) while forcing the registry to keep a hash-to-wallet reverse index and to accept duplicate registrations. Trading a real limitation for an imaginary guarantee was the wrong trade.
+
+### 2. Recovery still trusts the token agent completely
+
+`_investorOnchainID` is agent-supplied and unvalidated by `Token.sol`, so a compromised agent can pass a contract that vouches for any wallet and move any holder's position to an address of their choosing. That is a property of ERC-3643's recovery design, not of this registry, and no registry-side check can fix it — but it belongs in the threat model of any deployment relying on `recoveryAddress`.
+
+### 3. No identity data is kept
+
+The contract's entire state is the inherited address set. Nothing else is stored:
+
+| ERC-3643 concept | Here |
+| --- | --- |
+| ONCHAINID (`_identity`) | Echoed in `IdentityRegistered`, never stored. No `identity()` getter. |
+| Investor country (`_country`) | Ignored on write; `investorCountry` is a constant `0`. |
+| Claims / claim topics | Not modelled at all. |
+
+Anything expecting `identityRegistry.identity(wallet)` to return a usable ONCHAINID will not work against this registry.
+
+#### How much does the missing country actually matter?
+
+Less than it sounds, because the token barely uses it. Auditing the reference implementation (`lib/ERC-3643/`) for every consumer of `investorCountry`:
+
+| Location | Role |
+| --- | --- |
+| `token/Token.sol:308` (`recoveryAddress`) | **The only use in the token.** A pure pass-through: reads the lost wallet's country and hands it straight to `registerIdentity` for the new wallet. The token never branches on the value, never stores it, and exposes no getter — `IToken.sol` does not mention country at all. |
+| `registry/implementation/IdentityRegistryStorage.sol:91,112,177` | Storage plumbing: writes and reads the field. |
+| `registry/implementation/IdentityRegistry.sol:132,226` | Forwards to storage. |
+| `compliance/legacy/BasicCompliance.sol:175` | `_getCountry()`, the only place country drives *logic*, and it has **no caller** in the vendored tree; it exists for country-restriction modules built on top. Note the path: `legacy`. |
+| `_testContracts/` | `MockContract`, `LegacyToken_3_5_2`. |
+
+Two things follow:
+
+- **The token is genuinely unaffected.** Its single use is a round trip that starts and ends in the registry, so a constant `0` in and a discarded `0` out changes nothing. Recovery works exactly as it does with a full registry.
+- **The current modular compliance framework has no country module.** `compliance/modular/modules/` contains only `AbstractModule`, `AbstractModuleUpgradeable`, `IModule`, `ModuleProxy` and `TestModule` — none reference country.
+
+So the real exposure is narrow and specific: **a custom compliance module that calls `investorCountry` will see every investor as country 0**, and will therefore apply whatever rule it has for country 0 to everyone. Nothing shipped in ERC-3643 does this, but a jurisdiction-restriction module is a plausible thing to write. If that is on your roadmap, this registry is the wrong base — use the full ERC-3643 registry stack, which stores the country properly.
+
+If you need investor metadata on-chain generally, the same conclusion applies: this is a whitelist wearing the registry interface, nothing more.
+
+### 4. No claim topics, no trusted issuers
+
+`IClaimTopicsRegistry` and `ITrustedIssuersRegistry` are not implemented and are not referenced. Whether an investor qualifies is an off-chain decision, expressed on-chain by the registrar's `registerIdentity` call. If you need on-chain claim verification, this is the wrong contract — use the full ERC-3643 registry stack.
+
+### 5. Only the token-facing interface exists
+
+`contains`, `identity`, `updateIdentity`, `updateCountry`, `batchRegisterIdentity`, `identityStorage`, `issuersRegistry` and `topicsRegistry` are **not** implemented. None are called by `Token.sol`, so the registry is a complete drop-in for a token, but it is **not** a complete `IIdentityRegistry`: third-party tooling that expects the full interface will revert. To update a country, call `registerIdentity` again (see Limitation 2).
+
+## Tests
+
+| File | Covers |
+| --- | --- |
+| `test/IdentityRegistryWhitelist/IdentityRegistryWhitelistUnit.t.sol` | Registration, deletion, duplicate and zero-address rejection, access control, and that no identity data is stored |
+| `test/IdentityRegistryWhitelist/IdentityRegistryWhitelistERC3643.t.sol` | End-to-end against an ERC-3643 token: `mint`, `transfer`, `transferFrom`, `forcedTransfer`, `burn`, `recoveryAddress` |
+| `test/ERC3643Compliance/ERC3643RuleEngineWhitelist.t.sol` | The **other** slot: an ERC-3643 token with a `RuleEngine` as its compliance contract, enforcing `RuleWhitelist`, alongside this registry on the identity slot |
+
+The integration tests use `ERC3643TokenMock` plus `OnchainIdMock` (a minimal ERC-734 stub standing in for the investor's identity), whose registry call sequences and revert strings are transcribed from the reference `Token.sol`. The real implementation is not used because it imports the ONCHAINID Solidity package (not vendored) and targets OpenZeppelin v4 upgradeable contracts, while this repository vendors OZ v5 — it does not compile in this build. The mock keeps the registry interaction faithful and omits what is orthogonal to it (compliance module, pausing, partial-freeze accounting).
diff --git a/doc/technical/RuleBlacklist.md b/doc/technical/contracts/RuleBlacklist.md
similarity index 90%
rename from doc/technical/RuleBlacklist.md
rename to doc/technical/contracts/RuleBlacklist.md
index da320e12..ff3712bf 100644
--- a/doc/technical/RuleBlacklist.md
+++ b/doc/technical/contracts/RuleBlacklist.md
@@ -10,17 +10,17 @@ A significant portion of the address-list management code is shared with the whi
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer.
-
+
_Diagram source: doc/img/rule-blacklist-flow.puml._
diff --git a/doc/technical/contracts/RuleChainlinkPoR.md b/doc/technical/contracts/RuleChainlinkPoR.md
new file mode 100644
index 00000000..c4e6156b
--- /dev/null
+++ b/doc/technical/contracts/RuleChainlinkPoR.md
@@ -0,0 +1,327 @@
+# Rule Chainlink Proof of Reserve
+
+[TOC]
+
+`RuleChainlinkPoR` ensures the total supply of a token never exceeds the reserves actually backing it. Before every mint it reads the latest reserve value from a [Chainlink Proof of Reserve](https://docs.chain.link/data-feeds/proof-of-reserve) data feed and checks whether the new total supply (current supply plus the requested mint amount) would exceed what the reserves can back. If it would, the mint is rejected.
+
+The maximum mintable supply equals the reported reserves **exactly**: there is no margin, buffer or headroom parameter. If you need a safety cushion, express it upstream (report conservative reserves on the feed) or compose with `RuleMaxTotalSupply` for a static ceiling.
+
+Only mint operations (`from == address(0)`) are gated. Plain transfers do not change the total supply, and burns only reduce it, so both always pass — including while the feed is stale or unavailable. This is deliberate: a lapsed feed must never trap holders in their position.
+
+The rule is modelled on Chainlink's [`SecureMintPolicy`](https://docs.chain.link/ace/reference/policy-library/secure-mint-policy) from the ACE policy library, re-expressed as an ERC-1404 / ERC-3643 compliance rule for this library and deliberately simplified: the ACE policy's configurable reserve margin is not carried over.
+
+## Contract layout
+
+The rule is split in two, so feed handling does not drag the rest of the rule along with it.
+
+| Contract | Holds | Depends on |
+| --- | --- | --- |
+| [`ChainlinkPoRFeedManager`](../../../src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol) | The feed, the protected token, `tokenDecimals`, `maxStalenessSeconds`, their setters and the revert-free reserve read (`maxBackedSupply`, decimal scaling) | `TokenSupplyReader`, the invariant storage. **No constructor, no ERC-1404** |
+| [`RuleChainlinkPoRBase`](../../../src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol) | The constructor, the ERC-1404 / ERC-3643 surface (`canReturnTransferRestrictionCode`, `messageForTransferRestriction`, `transferred`) and the restriction logic that turns a backed supply into a code | `RuleTransferValidation`, `ChainlinkPoRFeedManager` |
+
+The manager declaring no constructor is the point of the split: it exposes `_setReservesFeed`,
+`_setTokenMetadata` and `_setMaxStalenessSeconds` and leaves *when* they run to the inheritor.
+`RuleChainlinkPoRBase` calls all three from its constructor; an upgradeable variant would call the
+same three from an initializer, with no change to the manager. Nothing in the manager references a
+restriction-code interface either, so a contract that only wants a revert-free view of
+reserve-backed supply can inherit it without implementing an ERC-1404 surface it does not need.
+
+Storage layout and the deployed ABI are **unchanged** by the split, verified per-slot from the
+compiled artifacts for both `RuleChainlinkPoR` and `RuleChainlinkPoROwnable2Step`.
+
+## Token compatibility: ERC-20 only
+
+`RuleChainlinkPoR` is **not usable with an ERC-721 or ERC-1155 token**, for two independent reasons:
+
+- **No ERC-7943 entrypoints.** The rule inherits `RuleTransferValidation` directly, not `RuleNFTAdapter`, so the `tokenId`-carrying overloads (`detectTransferRestriction(from, to, tokenId, amount)`, `transferred(from, to, tokenId, value)`, …) and the `ITransferContext` struct entrypoints do not exist on it, and it does not advertise `IERC7943NonFungibleComplianceExtend` through ERC-165. See the overload matrix in [`RULE_SEMANTICS.md`](../guides/RULE_SEMANTICS.md#3-overload-surface-erc-7943-tokenid--itransfercontext).
+- **An aggregate `totalSupply()` is mandatory.** Configuration probes it and reverts with `RuleChainlinkPoR_TokenTotalSupplyUnavailable` when it is absent. Plain ERC-721 has no `totalSupply()` — only `ERC721Enumerable` does — and ERC-1155 supply is per token id (`ERC1155Supply.totalSupply(id)`), so an aggregate figure mixes every id together and a reserve cap derived from it means nothing for a multi-id collection.
+
+This is a design choice, not an omission: the rule caps a *fungible supply* against a reserve figure, so a `tokenId` dimension carries no information for it. `RuleMaxTotalSupply` is ERC-20 only for the same reason. To cap issuance of a non-fungible asset, screen the participants with an address-based validation rule (`RuleWhitelist`, `RuleReceiverWhitelist`, …), all of which do expose the ERC-7943 overloads.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+## Configuration
+
+### Constructor parameters
+
+| Parameter | Description |
+| --- | --- |
+| `admin` / `owner` | Address granted `DEFAULT_ADMIN_ROLE` (AccessControl variant) or set as owner (Ownable2Step variant) |
+| `tokenContract_` | Address of the protected token; must expose `totalSupply()` and be non-zero |
+| `tokenDecimals_` | Decimals of that token, `0` to `18`; validated against `decimals()` when the token exposes it |
+| `reservesFeed_` | Proof of Reserve data feed implementing `AggregatorV3Interface`; must be a contract |
+| `maxStalenessSeconds_` | Maximum accepted age of the reserve data, in seconds; `0` disables the check |
+
+Every value can be updated after deployment by the rule manager.
+
+### Proof of Reserve feed
+
+Any contract implementing `AggregatorV3Interface` works; in practice this is a Chainlink Proof of Reserve feed (see the [Data Feeds addresses page](https://docs.chain.link/data-feeds/smartdata/addresses)). Each rule instance supports **one** feed. If the token is backed by several reserve sources, deploy one `RuleChainlinkPoR` per feed and add them all to the same `RuleEngine` — the engine applies them conjunctively, so every feed must back the mint.
+
+Configuration reverts if the feed's `decimals()` call fails (`RuleChainlinkPoR_FeedDecimalsUnavailable`) or reports more than `MAX_FEED_DECIMALS` (36), so a misconfigured feed is rejected up front rather than silently blocking every mint later.
+
+#### Why the decimals are read live, and what it costs
+
+The feed's `decimals()` is read **on every check** and never cached. The value validated at configuration time is only used to reject a bad feed early; it is not stored.
+
+**The risk this avoids.** Caching is the obvious optimisation, since decimals are a near-immutable property of a feed, so re-reading them looks wasteful. The problem is the failure mode when that assumption breaks. Chainlink feeds are proxies (`EACAggregatorProxy`) that delegate `decimals()` to whichever aggregator is currently installed. If an aggregator migration changed the reported decimals and the rule were still using a cached value, every subsequent reserve reading would be mis-scaled by `10 ** delta` — with **no revert, no event and no other on-chain signal**. In the direction that overstates reserves, an 8→18 migration against a cached `8` inflates the apparent backing by `10 ** 10`, which authorises essentially unlimited unbacked minting. That is precisely the outcome this rule exists to prevent, so it is not a risk worth trading for gas.
+
+**The cost.** One extra `STATICCALL` per restriction check:
+
+| Measurement | Cached | Live | Delta |
+| --- | --- | --- | --- |
+| End-to-end CMTAT mint through a RuleEngine | 111,184 | 114,106 | **+2,922** (+2.6%) |
+| Single `detectTransferRestriction` (cold feed account) | 56,029 | 59,075 | +3,046 |
+| Each further check in the same transaction (warm account) | — | — | ≈ +900 |
+
+Roughly 2.6% of a mint, paid only on the mint path; transfers and burns short-circuit before any feed access and are completely unaffected.
+
+**Why this is safe for a MUST-NOT-revert view.** Reading live adds a second external call that could fail, so both feed calls are guarded identically: the `code.length` check covers both (a `try` to a codeless address reverts *uncatchably*, so `try/catch` alone would not be enough — see the deployment-precondition section for why, and why the mechanism is the ABI decoder rather than `extcodesize`), and a reverting `decimals()` returns `CODE_RESERVES_ANSWER_INVALID`. The `MAX_FEED_DECIMALS` bound is additionally **re-checked at read time**, not just at configuration — otherwise a feed that raised its decimals past the bound would overflow the scaling exponent and revert the view.
+
+**Residual risk.** The scaling now always agrees with what the feed reports *at the moment of the check*, so there is no stale-cache window. What remains is that a feed changing decimals mid-life still changes the meaning of the reserve figure between one block and the next; the rule follows it faithfully rather than silently using an outdated scale, but an operator monitoring a feed migration should still confirm the new aggregator reports the reserve they expect.
+
+### Token metadata
+
+`tokenContract` is called with `totalSupply()` on every mint; `tokenDecimals` is used to scale the feed answer into token units. When the token exposes `decimals()`, the configured value is checked against it and a mismatch reverts. `0` decimals is accepted and is the common case for CMTAT equity tokens.
+
+Configuration validates the token in three ways: it must not be the zero address, it must have code (`RuleChainlinkPoR_TokenIsNotAContract`), and `totalSupply()` must be callable (`RuleChainlinkPoR_TokenTotalSupplyUnavailable`). `decimals()` remains **optional** — a token without it is accepted and the configured value is used as-is — but `totalSupply()` is mandatory, because the restriction check cannot work without it. Probing at configuration turns what would otherwise be a silent read-path failure into an immediate, named configuration error.
+
+> **Warning: decimal scaling.** For a token that does **not** expose `decimals()`, the configured value is used as-is. An incorrect value skews the reserve comparison in either direction, allowing over-minting or blocking valid mints. Verify the token's real decimals before configuring.
+
+#### Truncation when the feed is finer-grained than the token
+
+When `feedDecimals > tokenDecimals` the answer is divided, and the division **truncates**. Truncation always rounds the backed supply *down*, so the rule can under-mint but never over-mint — the safe direction for a reserve check.
+
+This is most visible at `tokenDecimals == 0` (CMTAT equity tokens), where the divisor is the largest it can be for a given feed:
+
+| Feed answer (8 decimals) | Backed supply, `tokenDecimals = 0` |
+| --- | --- |
+| `1000.99999999` | 1000 |
+| `1000.00000000` | 1000 |
+| `0.99999999` | 0 — every non-zero mint rejected |
+
+The last row is the case to be aware of operationally: with a 0-decimals token, reserves below one whole unit back nothing at all. That is arithmetically correct — you cannot issue a whole share against a fractional reserve — but it means a feed reporting a small residual balance blocks issuance entirely rather than allowing a token or two.
+
+Behaviour across the decimals domain is pinned by [`test/RuleChainlinkPoR/RuleChainlinkPoRDecimals.t.sol`](../../../test/RuleChainlinkPoR/RuleChainlinkPoRDecimals.t.sol), which includes a fuzz cross-checking the implementation against `answer * 10**tokenDecimals / 10**feedDecimals` computed with full-precision `mulDiv`.
+
+### Staleness threshold
+
+`maxStalenessSeconds` is the maximum age of the reserve data before the rule rejects mints. Choose it from the **heartbeat** of the Proof of Reserve feed: the threshold should match or slightly exceed the heartbeat. A feed whose `updatedAt` is exactly `maxStalenessSeconds` old is still accepted; older is rejected.
+
+Setting the threshold to `0` disables the check, so the rule then accepts reserve data of any age. Do this only when the feed's freshness is guaranteed by other means.
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_RESERVES_EXCEEDED` | 75 | `totalSupply + value` would exceed the backed supply |
+| `CODE_RESERVES_FEED_STALE` | 76 | The feed has not been updated within `maxStalenessSeconds` |
+| `CODE_RESERVES_ANSWER_INVALID` | 77 | A round **was** returned but cannot be used: a negative reserve, or an incomplete round (`updatedAt == 0`) |
+| `CODE_RESERVES_FEED_UNAVAILABLE` | 79 | **No usable response** could be obtained: `decimals()` or `latestRoundData()` reverted, or the feed reports more than `MAX_FEED_DECIMALS` |
+| `CODE_TOTAL_SUPPLY_UNAVAILABLE` | 78 | `tokenContract.totalSupply()` reverted, or the token has lost its code |
+
+## Access Control
+
+| Variant | Gate |
+| --- | --- |
+| `RuleChainlinkPoR` | `DEFAULT_ADMIN_ROLE` (the default admin implicitly holds all roles) |
+| `RuleChainlinkPoROwnable2Step` | Contract owner, with two-step ownership transfer |
+
+All three setters are gated on `_authorizeChainlinkPoRManager()`.
+
+## Methods
+
+### `setReservesFeed(AggregatorV3Interface newReservesFeed)`
+
+Replaces the data feed. Reverts on the zero address, on an address with no code, when `decimals()` reverts, or when it reports more than 36. Validation only, the value is not stored. Emits `ReservesFeedUpdated`, whose `feedDecimals` argument records what the feed reported at configuration time.
+
+### `setTokenMetadata(address newTokenContract, uint8 newTokenDecimals)`
+
+Replaces the protected token and its decimals. Reverts on the zero address, on decimals above 18, and on a mismatch with the token's own `decimals()` when exposed. Emits `TokenMetadataUpdated`.
+
+### `setMaxStalenessSeconds(uint256 newMaxStalenessSeconds)`
+
+Updates the staleness threshold. Emits `MaxStalenessSecondsUpdated`.
+
+### `maxBackedSupply() → (uint8 restrictionCode, uint256 backedSupply)`
+
+Previews the limit a mint is measured against, without simulating one. `restrictionCode` is `0` when the feed answer is usable, otherwise it is the code a mint would return (76 or 77) and `backedSupply` is `0`. Never reverts.
+
+### `feedDecimals() → uint8`
+
+Forwards the feed's current `decimals()`, so it always agrees with what the restriction checks use. Unlike the ERC-1404 views this getter is allowed to revert: it propagates whatever the feed does, which is the honest answer for a diagnostic accessor.
+
+### View getters
+
+`reservesFeed()`, `tokenContract()`, `tokenDecimals()`, `maxStalenessSeconds()`.
+
+## Transfer restriction logic
+
+For a mint (`from == address(0)`):
+
+1. Read `decimals()` and then `latestRoundData()` from `reservesFeed`.
+2. Reject with `CODE_RESERVES_FEED_UNAVAILABLE` if either call reverts or the feed reports more than `MAX_FEED_DECIMALS`: there is no answer to judge.
+3. Reject with `CODE_RESERVES_ANSWER_INVALID` if a round was returned but `answer < 0` or `updatedAt == 0`.
+4. Reject with `CODE_RESERVES_FEED_STALE` if `maxStalenessSeconds != 0` and `block.timestamp - updatedAt > maxStalenessSeconds`.
+5. Scale the answer from the feed's live decimals to `tokenDecimals` to obtain `backedSupply`.
+6. Read `tokenContract.totalSupply()`; reject with `CODE_TOTAL_SUPPLY_UNAVAILABLE` if it reverts or the token has lost its code.
+7. Reject with `CODE_RESERVES_EXCEEDED` if `totalSupply + value > backedSupply`.
+
+Anything else returns `TRANSFER_OK`. `detectTransferRestrictionFrom` delegates to the same logic and **ignores the spender**: this rule caps supply, it does not screen the minter.
+
+### Read-path safety
+
+`detectTransferRestriction*` and `canTransfer*` are ERC-1404 / ERC-3643 views that MUST NOT revert. The implementation therefore:
+
+- wraps `decimals()` and `latestRoundData()` in `try/catch`;
+- re-checks the `MAX_FEED_DECIMALS` bound against the live value, so the scaling exponent cannot overflow even if the feed changes;
+- saturates instead of overflowing when scaling up (`answer * 10 ** (tokenDecimals - feedDecimals)`);
+- bounds `tokenDecimals` at 18 at configuration time, so the scale-up factor is at most `10 ** 18`;
+- compares against the remaining headroom (`value > backedSupply - currentSupply`) instead of computing `currentSupply + value`, which could overflow;
+- wraps `tokenContract.totalSupply()` in `try/catch`, yielding code `78` instead of reverting.
+
+#### Why two feed-failure codes
+
+`79` and `77` both block the mint, so the *token* behaves identically. They are separated because they tell an
+operator different things, and the restriction code is the only channel available, because the read path cannot revert
+with data, and a view cannot emit an event.
+
+| Code | Meaning | What an operator checks |
+| --- | --- | --- |
+| `79` | The feed could not be read at all | Feed liveness; is the configured address a compatible `AggregatorV3Interface`? |
+| `77` | A round came back and its contents are unusable | Is this really a Proof of Reserve feed (a price feed can legitimately go negative)? Or wait for the round to complete. |
+
+`80` is left reserved. Splitting `79` further into "reverted" versus "decimals out of range" was considered and
+rejected: both mean the configured feed cannot be used, so the remedy is the same.
+
+#### Deployment precondition: EIP-6780 (Cancun or later)
+
+`try/catch` does **not** catch a call to an address with no code, and the reason is not the one usually quoted.
+`catch` handles a revert raised by the *callee*; it cannot handle a failure in this contract's own frame.
+
+For a call that returns data (`totalSupply()`, `decimals()`, `latestRoundData()`), **Solidity 0.8.10 and later
+skip the `EXTCODESIZE` check entirely** and rely on the ABI decoder instead. The `CALL` to a codeless account
+*succeeds*, returning 0 bytes; the decoder then fails to read the expected values from nothing, in the caller's
+frame, after the call has already returned. There is no callee revert for `catch` to attach to. (For a call
+returning *nothing*, the `EXTCODESIZE` check is still emitted and reverts before any call is made. Also
+uncatchable, different mechanism.)
+
+Confirm it in one step: point the rule at a contract that *has* code whose fallback succeeds and returns zero
+bytes. `EXTCODESIZE` passes, the `CALL` succeeds, and the view still reverts uncatchably.
+
+The revert-free guarantee therefore rests on `reservesFeed` and `tokenContract` still having code at read time —
+**and on their returning well-formed data**. Code alone is not sufficient: a proxy upgraded to an implementation
+whose fallback returns empty data keeps its code and still breaks the read path. Both are trusted inputs for
+this reason.
+
+That holds because the setters require code at configuration time (`RuleChainlinkPoR_FeedIsNotAContract`, `RuleChainlinkPoR_TokenIsNotAContract`), every write to either field goes through a validated setter, and **EIP-6780** (Cancun) restricts `SELFDESTRUCT` to accounts created in the same transaction — so a contract that exists across transactions can no longer be removed. A validated address stays a contract.
+
+There is deliberately **no runtime code-length re-check**: it would be unreachable code on any supported chain, and unreachable defensive code misrepresents the threat model. It is recorded here as a deployment precondition instead. `foundry.toml` targets `prague`, which is post-Cancun.
+
+> **If you deploy to a chain without EIP-6780** (post-Shanghai but pre-Cancun, as some L2s were for a period), this guarantee does not hold: a `SELFDESTRUCT`ed feed or token would make the ERC-1404 views revert instead of returning a code. Re-introduce an `address(x).code.length == 0` guard before each `try` if you target such a chain — it costs about 100 gas per call site, not the 2,600 a cold `EXTCODESIZE` suggests, because the account is warmed either way.
+
+The trust placed in `tokenContract` is narrower than it looks: it is trusted to report an **accurate** supply, which nothing on-chain can verify, but it is **not** trusted to stay callable. A token that is upgraded to something that reverts, or that reverts while paused, degrades to a restriction code rather than breaking the ERC-1404 contract. This is stricter than `RuleMaxTotalSupply`, which calls `totalSupply()` unguarded.
+
+### Failure modes are fail-closed for mints only
+
+A broken or stale feed blocks **minting**, never transfers or burns. This is the safe direction: the rule's purpose is to prevent unbacked issuance, and issuance can wait for the feed to recover. Holders retain full mobility of their existing balance throughout.
+
+## Usage scenario
+
+An issuer runs a tokenized commodity with a Chainlink Proof of Reserve feed reporting the custodian's holdings with 8 decimals; the token has 18 decimals and a 24 h feed heartbeat. They deploy:
+
+```solidity
+RuleChainlinkPoR rule = new RuleChainlinkPoR(
+ admin,
+ address(token),
+ 18, // token decimals
+ AggregatorV3Interface(porFeed),
+ 1 days + 1 hours // heartbeat plus slack
+);
+ruleEngine.addRule(rule);
+token.setRuleEngine(ruleEngine);
+```
+
+With reserves reported at 1 000 units, at most 1 000 tokens may exist. A mint that would push the supply past 1 000 reverts with code 75; a mint attempted more than 25 h after the last feed update reverts with code 76. When the custodian deposits more and the feed updates, the headroom reopens automatically — no rule reconfiguration needed.
+
+## Relationship to Chainlink's `SecureMintPolicy`
+
+This rule implements the same core idea as [`SecureMintPolicy`](https://github.com/smartcontractkit/chainlink-ace/blob/main/packages/policy-management/src/policies/SecureMintPolicy.sol) from the Chainlink ACE policy library (compared against `SecureMintPolicy 1.2.0`, vendored at `lib/chainlink-ace/`): read a Proof of Reserve feed before every mint and reject issuance the reserves cannot back. It is **not a port**. The two live in different execution models, and that drives most of the differences below.
+
+The decisive difference is **how a rejection is signalled**. `SecureMintPolicy.run()` reverts with `PolicyRejected`; it is called by an ACE `PolicyEngine` that only ever needs a yes/no at execution time. `RuleChainlinkPoR` must additionally satisfy the ERC-1404 / ERC-3643 read path, where `detectTransferRestriction` / `canTransfer` are views that **MUST NOT revert** and must return a numeric reason code. Every "returns a code where ACE reverts" row below follows from that one constraint.
+
+### Summary
+
+| Dimension | Chainlink `SecureMintPolicy` 1.2.0 | `RuleChainlinkPoR` |
+| --- | --- | --- |
+| Integration model | ACE `PolicyEngine`, bound to one selector | ERC-1404 / ERC-3643 rule, via `RuleEngine` or bound directly |
+| Rejection signalling | Reverts (`PolicyRejected`) | Returns restriction code `75` / `76` / `77`; reverts only on the write path |
+| Feed decimals | Read **live** on every `run()` | Read **live** on every check (same approach) |
+| Feed decimals bound | Unbounded (`uint8`) | `<= MAX_FEED_DECIMALS` (36), checked at configuration **and** at read time |
+| Feed call reverts (`decimals` or `latestRoundData`) | Propagates — mint reverts | `try/catch` → code `77` |
+| Incomplete round (`updatedAt == 0`) | Not checked | Code `77` |
+| Staleness arithmetic | `block.timestamp - updatedAt` — underflow-panics on a future timestamp | Guarded with `block.timestamp > updatedAt` |
+| Token decimals accepted | `1` to `18` | `0` to `18` (CMTAT equity tokens report 0) |
+| Reserve margin | 5 modes (percentage / absolute, positive / negative) | None — limit equals reserves exactly |
+| Scale-up overflow | Checked arithmetic → revert | Saturates at `type(uint256).max` |
+| Supply source | `subject`, supplied by the engine at call time | `tokenContract`, set in configuration |
+| Token validated at configuration | Zero-address check only | Zero-address, has-code, and `totalSupply()` probe |
+| `totalSupply()` reverting at run time | Propagates — mint reverts | Code `78` |
+| Single-token binding | Enforced — `onInstall` reverts with `PolicyAlreadyBound` | Not enforced (see below) |
+| Pre-flight preview | None | `maxBackedSupply()` |
+| Upgradeability | Upgradeable (ERC-7201 storage, initializers) | Non-upgradeable |
+| Access control | `onlyOwner` | `DEFAULT_ADMIN_ROLE` or `Ownable2Step` |
+| Setter no-op guards | Reverts if the new value equals the current one | No such guard |
+
+### Where this rule is stricter
+
+- **Feed failures degrade to a code, not a revert.** A feed with no code, a reverting `latestRoundData()`, a negative answer or an incomplete round all yield code `77`. ACE has no `updatedAt == 0` check at all, so with `maxStalenessSeconds == 0` an incomplete round is accepted at face value.
+- **No underflow on a future `updatedAt`.** ACE computes `block.timestamp - updatedAt` unguarded; a feed reporting a timestamp ahead of the block panics the whole call. Fail-closed for ACE, but a panic rather than a clean rejection.
+- **Feed decimals are bounded at configuration time**, so the scaling exponent can never overflow. ACE accepts any `uint8`, where a feed reporting e.g. 78 decimals makes `10 ** 78` revert on every mint.
+- **`0`-decimals tokens are supported.** ACE requires `decimals > 0`, which excludes CMTAT equity tokens outright.
+
+### Where ACE is stricter, and what this rule does instead
+
+- **One policy instance is pinned to one token.** `onInstall` records the `subject` and reverts with `PolicyAlreadyBound` on reuse, and `run()` rejects a call from any other subject. `RuleChainlinkPoR` has no equivalent guard — see [One instance per protected token](#one-instance-per-protected-token) for exactly what goes wrong and why the guard is absent.
+
+ In exchange, this rule avoids ACE's documented `subject` vs `tokenMetadata.tokenAddress` split, where the supply is read from one address while the decimals are validated against another and nothing enforces that they agree. Here `setTokenMetadata` sets the address and the decimals together and cross-checks the decimals against that same address.
+
+## Interaction with other rules
+
+- `RuleMaxTotalSupply` caps supply at a **static** value; `RuleChainlinkPoR` caps it at a **live, oracle-reported** one. They compose: add both to the same `RuleEngine` and the stricter one binds. This is also how you get a conservative buffer under the reserves, now that the rule itself has no margin parameter.
+- `RuleMintAllowance` limits how much **each minter** may issue; `RuleChainlinkPoR` limits how much **the token as a whole** may exist. Together they give a per-minter quota inside a reserve-backed ceiling.
+
+## Deployment topology
+
+The rule never reads `msg.sender` and holds no per-token binding, so it behaves identically in RuleEngine mode (Topology A) and direct mode (Topology B). See the topology section of `CLAUDE.md`.
+
+### One instance per protected token
+
+> **Warning.** A `RuleChainlinkPoR` instance protects exactly **one** token: the one in `tokenContract`. Nothing on-chain enforces that. Deploy a separate instance per token.
+
+**What the rule actually checks.** On every mint the rule reads `totalSupply()` from the **configured `tokenContract`**, never from whichever token triggered the check. It has no way to learn that identity: in Topology A the caller is the RuleEngine, and the `transferred(spender, from, to, value)` payload carries no token address.
+
+**What goes wrong.** Suppose one instance `R` is configured with `tokenContract = X` and added to both token X's RuleEngine and token Y's RuleEngine. A mint of `value` on **Y** is then evaluated as:
+
+```
+Y_mint_allowed ⟺ value ≤ backedSupply(X's feed) − totalSupply(X)
+```
+
+Y's own supply and Y's own reserves never enter the calculation. Both failure directions are live:
+
+- **Over-mint.** If X's supply sits far below what X's feed backs, the leftover headroom is silently handed to Y. Y can be minted against reserves that do not back it — the exact outcome this rule exists to prevent.
+- **Freeze.** If X is already at its cap, every Y mint is rejected with code `75` even when Y is fully backed.
+
+**Why it is easy to miss.** There is no revert, no event and no divergence in any getter. `maxBackedSupply()` faithfully reports the limit *for the configured token*, so a pre-flight check against the wrong instance looks perfectly healthy. The misconfiguration only surfaces as mints that are wrongly allowed or wrongly blocked.
+
+**Why there is no guard.** Adding one would mean giving a validation rule a binding and a stateful install/uninstall lifecycle, which is how the *operation* rules (`RuleConditionalTransferLight`, `RuleMintAllowance`) work but not the validation rules — those are deliberately stateless and shareable. `RuleMaxTotalSupply` has the identical exposure for the same reason. Changing that is a library-wide decision about whether supply-capping validation rules should be bindable, not something to special-case here.
+
+**Operational rule.** One `RuleChainlinkPoR` instance per protected token, and re-verify `tokenContract` whenever an instance is added to an additional RuleEngine or bound to an additional token. If a token is backed by several reserve sources, deploying one instance per feed (as described under [Proof of Reserve feed](#proof-of-reserve-feed)) is safe — those instances all share the same `tokenContract`, which is the intended configuration.
diff --git a/doc/technical/RuleConditionalTransfer.md b/doc/technical/contracts/RuleConditionalTransfer.md
similarity index 84%
rename from doc/technical/RuleConditionalTransfer.md
rename to doc/technical/contracts/RuleConditionalTransfer.md
index c6922472..e2245a91 100644
--- a/doc/technical/RuleConditionalTransfer.md
+++ b/doc/technical/contracts/RuleConditionalTransfer.md
@@ -4,6 +4,8 @@
This page describes a Conditional Transfer implementation. This rule requires that transfers have to be approved before being executed by the token holders.
+> **This rule is not part of this repository.** `RuleConditionalTransfer` is the full-featured, experimental variant maintained separately at [CMTA/RuleConditionalTransfer](https://github.com/CMTA/RuleConditionalTransfer). This page is kept for reference; the rules shipped here are [`RuleConditionalTransferLight`](./RuleConditionalTransferLight.md) and [`RuleConditionalTransferLightMultiToken`](./RuleConditionalTransferLightMultiToken.md). The diagrams referenced below live in the upstream repository and were never migrated here, so the embeds have been replaced with links.
+
In the Swiss law, this rule allows to implement a specific restriction called *Vinkulierung*.
@@ -63,23 +65,23 @@ This option, if activated, will perform the transfer if the request is approved
To perform the transfer, the token holder has to `approve` the rule to spend tokens on his behalf (standard ERC-20 approval). If the allowance is not sufficient, the request will be approved, but without performing the transfer.
-
+_Diagram: `conditionalTransfer-automaticTransfer.drawio.png` — see [the upstream repository](https://github.com/CMTA/RuleConditionalTransfer)._
## Schema
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows the full *Vinkulierung* workflow: a holder requests a transfer, an operator approves it, and the CMTAT token (with this rule configured in its RuleEngine) validates the approved request and marks it executed. The optional `AUTOMATIC_TRANSFER` path where the rule performs the transfer on approval is also shown.
-
+
_Diagram source: doc/img/rule-conditional-transfer-flow.puml._
@@ -91,15 +93,15 @@ Each request has a status, which changes regarding the decision of the operator
The default status is `NONE`.
-
+_Diagram: `conditionalTransfer-state machine.drawio.png` — see [the upstream repository](https://github.com/CMTA/RuleConditionalTransfer)._
#### With the CMTAT
-
+_Diagram: `conditionalTransferCMTAT.drawio.png` — see [the upstream repository](https://github.com/CMTA/RuleConditionalTransfer)._
#### With Storage
-
+_Diagram: `conditionalTransfer-Storage.drawio.png` — see [the upstream repository](https://github.com/CMTA/RuleConditionalTransfer)._
## Request
@@ -175,7 +177,7 @@ The default admin is the address put in argument(`admin`) inside the constructor
### Graph
-
+
diff --git a/doc/technical/RuleConditionalTransferLight.md b/doc/technical/contracts/RuleConditionalTransferLight.md
similarity index 69%
rename from doc/technical/RuleConditionalTransferLight.md
rename to doc/technical/contracts/RuleConditionalTransferLight.md
index 12ea6488..b6da8586 100644
--- a/doc/technical/RuleConditionalTransferLight.md
+++ b/doc/technical/contracts/RuleConditionalTransferLight.md
@@ -12,17 +12,17 @@ Mints (`from == address(0)`) and burns (`to == address(0)`) are **exempt**: they
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows the two-phase flow: an operator first approves a `(from, to, value)` transfer, then the CMTAT token (with this rule configured in its RuleEngine) validates and consumes that approval during the transfer.
-
+
_Diagram source: doc/img/rule-conditional-transfer-light-flow.puml._
@@ -94,7 +94,7 @@ rule.bindToken(address(cmtat)); // the ERC-20 target
rule.bindRuleEngine(address(ruleEngine)); // the authorized caller
```
-> **Why two bindings?** They were originally one slot, which had to be *both* the ERC-20 target and the authorized caller. In direct mode those coincide, so it worked. Behind a RuleEngine they are different addresses, and a single slot could only hold one: binding the engine broke `approveAndTransferIfAllowed` (the engine is not an ERC-20), while binding the token left the engine unauthorized and reverted **every** transfer and mint. Splitting the roles fixes both. See `RESULT.md` finding **F-3**.
+> **Why two bindings?** They were originally one slot, which had to be *both* the ERC-20 target and the authorized caller. In direct mode those coincide, so it worked. Behind a RuleEngine they are different addresses, and a single slot could only hold one: binding the engine broke `approveAndTransferIfAllowed` (the engine is not an ERC-20), while binding the token left the engine unauthorized and reverted **every** transfer and mint. Splitting the roles fixes both. See `CLAUDE_AUDIT.md` finding **F-3**.
Always bind the **token** with `bindToken` — putting the RuleEngine there instead makes `getTokenBound()` a non-ERC-20 and `approveAndTransferIfAllowed` will revert.
@@ -139,6 +139,50 @@ Each call to `approveTransfer` increments the counter by 1. Each successful tran
For `transferred(spender, from, to, value)`, the spender address is ignored. Approval lookup is based solely on `(from, to, value)`.
+### Zero-value transfers are not treated as no-ops
+
+**Open conformance gap against CMTAT `v3.3.0-rc3`.** That release added a normative requirement to
+`IRuleEngine`'s NatSpec:
+
+> Zero-value calls are permissionless. ERC-20 requires transfers of `0` to be treated as normal transfers, and
+> OpenZeppelin's `_spendAllowance` consumes no allowance when `value == 0`, so any address can call
+> `transferFrom(victim, anyone, 0)` and reach this callback for an arbitrary `from`, with itself as `spender`,
+> without ever having been approved. *"Implementations MUST therefore treat `value == 0` as carrying no
+> economic meaning: any stateful rule ... MUST be a no-op for a zero value."*
+
+This rule keys approvals on `(from, to, value)` with no special case for `value == 0`, so it does not yet
+satisfy that. Measured against the rule as shipped:
+
+| Call | Behaviour today |
+| --- | --- |
+| `transferred(attacker, victim, anyone, 0)` with no matching approval | **Reverts** — so a zero-value transfer that ERC-20 says should succeed is rejected |
+| An approval recorded for `(from, to, 0)` | **Consumable by any caller**, since the spender is not part of the key |
+
+Neither moves value, and the second requires an operator to have approved a zero-value transfer in the first
+place, which is a degenerate thing to do. The practical exposure is therefore low.
+
+**Decision: documented, not fixed, for `v0.5.0`.** The fix is an early return when `value == 0`, before any
+approval lookup or state change, on this rule and
+[`RuleConditionalTransferLightMultiToken`](./RuleConditionalTransferLightMultiToken.md). It is a **behaviour
+change** to shipped compliance logic — a zero-value transfer would stop reverting — so it was recorded rather
+than applied late in the release.
+
+**Which rules the fix would and would not cover**, measured against the code rather than inferred from the
+wording of the requirement:
+
+| Rule | `value == 0` today | In scope? |
+| --- | --- | --- |
+| This rule and `…MultiToken` | Consumes an approval, for a caller never approved | **Yes** |
+| [`RuleMaxBalance`](./RuleMaxBalance.md) | Rejects a receiver already **over** the cap | **Yes** — receiving nothing cannot breach a holding cap |
+| `RuleMintAllowance` | Quota unchanged: debiting `0` is already a no-op, and the mint path is not permissionlessly reachable | **No** — nothing to change |
+| `RuleBlacklist`, `RuleWhitelist`, `RuleSanctionsList`, `RuleIdentityRegistry` | Blocked, as for any value | **No, deliberately** — these screen *who*, not *how much*. A zero-value transfer still emits a `Transfer` event linking the parties, so a blanket "zero is always allowed" would weaken every deny-list for no benefit |
+
+The strongest argument for the change is **ERC-20 conformance**, not the desynchronisation the requirement
+describes: ERC-20 says a zero-value transfer must be treated as a normal transfer, so a rule that reverts one
+makes the *token* non-conformant and breaks integrations that sweep balances or probe with a zero-value send.
+The attacker-desynchronises-state framing barely applies here — it needs an operator to have recorded a
+zero-value approval first.
+
### Duplicate approvals
Multiple approvals for the same `(from, to, value)` tuple are allowed and stack. This enables scenarios where the same transfer is expected to occur multiple times.
diff --git a/doc/technical/RuleConditionalTransferLightMultiToken.md b/doc/technical/contracts/RuleConditionalTransferLightMultiToken.md
similarity index 79%
rename from doc/technical/RuleConditionalTransferLightMultiToken.md
rename to doc/technical/contracts/RuleConditionalTransferLightMultiToken.md
index a668d988..9798d0eb 100644
--- a/doc/technical/RuleConditionalTransferLightMultiToken.md
+++ b/doc/technical/contracts/RuleConditionalTransferLightMultiToken.md
@@ -8,7 +8,7 @@ Approval key:
- `keccak256(token, from, to, value)`
-This prevents approval reuse across tokens — **provided the rule receives token-specific caller context, which only happens when each token calls the rule directly.**
+This prevents approval reuse across tokens, **provided the rule receives token-specific caller context, which only happens when each token calls the rule directly.**
> ## ⚠️ Deployment requirement: bind this rule **directly to each token**
>
@@ -16,16 +16,16 @@ This prevents approval reuse across tokens — **provided the rule receives toke
>
> The reason is structural: approvals are **recorded** under the `token` argument you pass to `approveTransfer`, but **consumed** under `msg.sender`. Those two keys agree only when the caller *is* the token. Behind a `RuleEngine` the caller is the engine, and the rule cannot do its job — see [Deployment topology](#deployment-topology--why-a-ruleengine-does-not-work) for the exhaustive case analysis.
>
-> "MultiToken" means *several tokens each pointing directly at one shared rule* — not *one RuleEngine serving several tokens*.
+> "MultiToken" means *several tokens each pointing directly at one shared rule*, not *one RuleEngine serving several tokens*.
## Deployment topology — why a RuleEngine does not work
Two guards determine what is possible:
-- `_authorizeTransferExecution()` — `require(isTokenBound(msg.sender))`: **the caller must be bound.**
-- `_approveTransfer()` — `require(isTokenBound(token))`: **the `token` argument must be bound.**
+- `_authorizeTransferExecution()`: `require(isTokenBound(msg.sender))`: **the caller must be bound.**
+- `_approveTransfer()`: `require(isTokenBound(token))`: **the `token` argument must be bound.**
-Behind a `RuleEngine` (`CMTAT.setRuleEngine(engine)` + `engine.addRule(rule)`), the engine — not the token — is the `msg.sender` of every `transferred()` call. Every possible wiring then fails:
+Behind a `RuleEngine` (`CMTAT.setRuleEngine(engine)` + `engine.addRule(rule)`), the engine, not the token, is the `msg.sender` of every `transferred()` call. Every possible wiring then fails:
| # | Wiring | Outcome |
| --- | --- | --- |
@@ -33,14 +33,14 @@ Behind a `RuleEngine` (`CMTAT.setRuleEngine(engine)` + `engine.addRule(rule)`),
| B | Bind the **token** | The engine is still the caller and is not bound → same revert. Binding the token achieves nothing, because the token never calls the rule. |
| C | Bind the **engine**, approve with the **token** address | `approveTransfer(token, …)` → `token` is not bound → reverts `RuleConditionalTransferLightMultiToken_InvalidToken`. The approval cannot even be recorded. |
| C′ | Bind **both**, approve with the **token** address | Approval stored under `H(token, …)`; the engine consumes under `H(engine, …)` → reverts `TransferNotApproved`, and the approval is **stranded in storage permanently**. |
-| D | Bind the **engine**, approve with the **engine** address | The only configuration that runs — and it defeats the rule's purpose (see below). |
+| D | Bind the **engine**, approve with the **engine** address | The only configuration that runs, and it defeats the rule's purpose (see below). |
Case D "works" but is not a valid deployment:
1. **No per-token isolation.** The approval key is the engine, not the token. An approval recorded for `(alice → bob, 100)` intending token A is equally consumable on token B. This is exactly the cross-token approval reuse this rule exists to prevent.
2. **The `token` parameter becomes misleading.** The operator must pass the *engine* address into a parameter named `token`. Reading the API as written lands you in case C′ and strands approvals.
3. **`approveAndTransferIfAllowed` cannot work.** It calls `IERC20(token).allowance(...)` on what is actually the engine → revert.
-4. **It is strictly worse than the single-token rule.** Case D is only safe when the engine serves exactly one token — and in that situation [`RuleConditionalTransferLight`](./RuleConditionalTransferLight.md) does the same job with an honest API and a working `approveAndTransferIfAllowed`.
+4. **It is strictly worse than the single-token rule.** Case D is only safe when the engine serves exactly one token, and in that situation [`RuleConditionalTransferLight`](./RuleConditionalTransferLight.md) does the same job with an honest API and a working `approveAndTransferIfAllowed`.
**Correct deployment (direct binding).** Each token calls the rule itself, so `msg.sender == tokenX`, the approve key and the consume key are both `H(tokenX, …)`, and approvals are genuinely isolated per token:
@@ -51,23 +51,23 @@ CMTAT_B.setRuleEngine(rule); rule.bindToken(address(CMTAT_B));
rule.approveTransfer(address(CMTAT_A), alice, bob, 100); // usable on CMTAT_A only
```
-Supporting true per-token scoping behind a `RuleEngine` would require the token address to be threaded into `IRuleEngine.transferred(...)`, which is an upstream RuleEngine interface change and is not possible from this repository. See `RESULT.md` finding **F-4**.
+Supporting true per-token scoping behind a `RuleEngine` would require the token address to be threaded into `IRuleEngine.transferred(...)`, which is an upstream RuleEngine interface change and is not possible from this repository. See `CLAUDE_AUDIT.md` finding **F-4**.
## Schema
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows the two-phase flow with token-scoped approvals in the supported topology (**the rule bound directly to the token**): an operator approves a `(token, from, to, value)` transfer, then the CMTAT token validates and consumes that approval.
-
+
_Diagram source: doc/img/rule-conditional-transfer-light-multitoken-flow.puml._
@@ -111,7 +111,7 @@ Approves and executes `safeTransferFrom` on the specified token, requiring allow
### `transferred(...)`
-Only bound tokens can call transfer execution hooks. Approval consumption uses the **caller** (`msg.sender`) as the token key — which is why the rule must be bound directly to each token. See [Deployment topology](#deployment-topology--why-a-ruleengine-does-not-work).
+Only bound tokens can call transfer execution hooks. Approval consumption uses the **caller** (`msg.sender`) as the token key, which is why the rule must be bound directly to each token. See [Deployment topology](#deployment-topology--why-a-ruleengine-does-not-work).
### `detectTransferRestrictionForToken(address token, address from, address to, uint256 value) -> uint8`
@@ -123,7 +123,7 @@ Boolean counterpart of `detectTransferRestrictionForToken`. Prefer it over `canT
### `detectTransferRestriction(from, to, value)` / `canTransfer(from, to, value)`
-⚠️ **Caller-dependent — prefer the `…ForToken` views above.** These ERC-1404 / ERC-3643 views derive the token key from `msg.sender`, so they only return a meaningful answer when invoked *by the bound token*. Any other caller always receives `CODE_TRANSFER_REQUEST_NOT_APPROVED` (46) / `false`, even for a transfer that is approved and will succeed. They are fail-closed, but carry no signal for third-party pre-flight. See `RESULT.md` finding **F-8**.
+⚠️ **Caller-dependent; prefer the `…ForToken` views above.** These ERC-1404 / ERC-3643 views derive the token key from `msg.sender`, so they only return a meaningful answer when invoked *by the bound token*. Any other caller always receives `CODE_TRANSFER_REQUEST_NOT_APPROVED` (46) / `false`, even for a transfer that is approved and will succeed. They are fail-closed, but carry no signal for third-party pre-flight. See `CLAUDE_AUDIT.md` finding **F-8**.
The standardized signatures are kept as-is (the token cannot be added to them without breaking ERC-1404), and both the implicit and explicit views are backed by the same internal helper, so for the bound token they can never disagree.
diff --git a/doc/technical/RuleERC2980.md b/doc/technical/contracts/RuleERC2980.md
similarity index 96%
rename from doc/technical/RuleERC2980.md
rename to doc/technical/contracts/RuleERC2980.md
index 497dc8da..791f1223 100644
--- a/doc/technical/RuleERC2980.md
+++ b/doc/technical/contracts/RuleERC2980.md
@@ -12,17 +12,17 @@ This rule implements the [ERC-2980](https://eips.ethereum.org/EIPS/eip-2980) Swi
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer. The frozenlist is evaluated before the recipient whitelist.
-
+
_Diagram source: doc/img/rule-erc2980-flow.puml._
diff --git a/doc/technical/RuleIdentityRegistry.md b/doc/technical/contracts/RuleIdentityRegistry.md
similarity index 75%
rename from doc/technical/RuleIdentityRegistry.md
rename to doc/technical/contracts/RuleIdentityRegistry.md
index 8c59fa57..33f05a58 100644
--- a/doc/technical/RuleIdentityRegistry.md
+++ b/doc/technical/contracts/RuleIdentityRegistry.md
@@ -4,12 +4,24 @@
This rule checks an [ERC-3643](https://eips.ethereum.org/EIPS/eip-3643) Identity Registry to verify that transfer participants are registered and verified.
+> ### When this rule is the right tool
+>
+> Reach for it when the token **cannot consult a registry itself**.
+>
+> - **CMTAT has no identity registry slot.** `setIdentityRegistry` is an ERC-3643 concept with no CMTAT
+> equivalent, so this rule behind a `RuleEngine` is the *only* way to apply identity-registry screening to a
+> CMTAT token. It consults whichever registry you point it at — an ONCHAINID-backed ERC-3643 registry, or
+> [`IdentityRegistryWhitelist`](./IdentityRegistryWhitelist.md) if you have no ONCHAINID deployment.
+> - **On an ERC-3643 token, prefer the token's own slot.** That token already calls `isVerified` on the
+> registry for every transfer. Adding this rule on top screens the same wallets a second time and adds no
+> restriction, so install the registry with `setIdentityRegistry` instead.
+
> ## ✅ ERC-3643 conformant: only the RECEIVER is verified
>
> The specification mandates exactly one identity check:
>
> - *"The **receiver** MUST be whitelisted on the Identity Registry and verified"* (§ Transfer)
-> - *"`transferFrom` **works the same way**"* — receiver only
+> - *"`transferFrom` **works the same way**"*: receiver only
> - *"`mint` and `forcedTransfer` **only require the receiver** to be whitelisted and verified"*
> - *"The `burn` function **bypasses all checks** on eligibility"*
>
@@ -38,17 +50,17 @@ If no identity registry is configured (`address(0)`), all transfers pass this ru
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer, including the ERC-3643 identity registry `isVerified` lookups and the no-registry pass-through case.
-
+
_Diagram source: doc/img/rule-identity-registry-flow.puml._
@@ -84,11 +96,11 @@ Returns the current identity registry address. Returns `address(0)` if none is s
## Transfer restriction logic
- If no registry is set → all transfers pass.
-- **Burns (`to == address(0)`) always pass** — ERC-3643: *"The `burn` function bypasses all checks on eligibility."*
+- **Burns (`to == address(0)`) always pass**. ERC-3643: *"The `burn` function bypasses all checks on eligibility."*
- For all other transfers, including **mint**:
- **`to` is ALWAYS checked.** This is the only check ERC-3643 mandates.
- - `from` is checked **only if `checkSender` is enabled** (off by default — stricter than the spec).
- - `spender` is checked **only if `checkSpender` is enabled** (off by default — stricter than the spec), and mint
+ - `from` is checked **only if `checkSender` is enabled** (off by default, stricter than the spec).
+ - `spender` is checked **only if `checkSpender` is enabled** (off by default, stricter than the spec), and mint
and burn are exempt from it regardless: the minter/burner acts on its own authority, not as a delegated spender.
This is what lets an **unverified minter** mint to a verified recipient, exactly as ERC-3643 requires
(*"`mint` … only require[s] the receiver to be whitelisted and verified"*).
diff --git a/doc/technical/contracts/RuleMaxBalance.md b/doc/technical/contracts/RuleMaxBalance.md
new file mode 100644
index 00000000..b24f0cfb
--- /dev/null
+++ b/doc/technical/contracts/RuleMaxBalance.md
@@ -0,0 +1,174 @@
+# Rule Max Balance
+
+[TOC]
+
+`RuleMaxBalance` caps how many tokens a single address may hold. One cap applies to every holder, and the
+operator may exempt specific addresses from it.
+
+The rule screens the **receiver**: a transfer is rejected when `balanceOf(to) + value > maxBalance`. Mints are
+covered by the same check, since a mint raises the receiver's balance exactly as a transfer does.
+
+> ## ⚠️ Do not deploy this rule on its own
+>
+> **The cap counts tokens per *address*, not per investor.** That is the only thing a compliance contract can
+> observe on-chain. An investor who wants more than `maxBalance` simply splits the position across two
+> addresses, and no rule objects, because each address is individually under the cap.
+>
+> **Pair it with a rule that admits one address per investor:**
+>
+> | Rule | What it contributes |
+> | --- | --- |
+> | [`RuleWhitelist`](./RuleWhitelist.md) | Only admitted addresses may send or receive |
+> | [`RuleReceiverWhitelist`](./RuleReceiverWhitelist.md) | Only admitted addresses may receive (ERC-3643 eligibility semantics) |
+> | [`RuleIdentityRegistry`](./RuleIdentityRegistry.md) | Only identity-verified addresses may receive |
+>
+> **The pairing is necessary but not sufficient — the operator policy is what closes the gap.** A whitelist
+> admits *addresses*. If the operator admits two wallets belonging to the same investor, the cap is doubled
+> again. The property you actually need is **one admitted address per legal entity**, enforced off-chain
+> during onboarding and reflected on-chain by admitting exactly one address per investor.
+>
+> This is pinned by
+> [`testSplitWalletsBypassTheCapEvenWithAWhitelist`](../../../test/RuleMaxBalance/RuleMaxBalanceCMTATIntegration.t.sol),
+> which deliberately admits both wallets of one investor and shows the combined holding reaching twice the cap
+> with a whitelist active. If that test ever fails, the bypass has been closed by other means and this warning
+> should be revisited.
+
+## Contract layout
+
+The rule is split in two, so cap management does not drag the rest of the rule along with it.
+
+| Contract | Holds | Depends on |
+| --- | --- | --- |
+| [`BalanceCapManager`](../../../src/rules/validation/abstract/core/BalanceCapManager.sol) | The observed token, the cap, the exemption list, their setters and the revert-free `balanceOf` read | `RuleAddressSetInternal`, the invariant storage. **No constructor, no ERC-1404** |
+| [`RuleMaxBalanceBase`](../../../src/rules/validation/abstract/base/RuleMaxBalanceBase.sol) | The constructor, the ERC-1404 / ERC-3643 surface and the logic mapping a breached cap to a restriction code | `RuleTransferValidation`, `BalanceCapManager` |
+
+The manager declaring no constructor is the point: it exposes the `_set*` internals and leaves *when* they run
+to the inheritor. `RuleMaxBalanceBase` calls them from its constructor; an upgradeable variant would call the
+same ones from an initializer, with no change to the manager. Nothing in the manager references a
+restriction-code interface either — it answers in booleans and token units — so a contract that only wants a
+revert-free view of remaining headroom can inherit it without implementing an ERC-1404 surface it does not
+need.
+
+Storage layout and the deployed ABI are **unchanged** by the split, verified per-slot from the compiled
+artifacts for both `RuleMaxBalance` and `RuleMaxBalanceOwnable2Step`.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_MAX_BALANCE_EXCEEDED` | 82 | The transfer would push the receiver's balance above `maxBalance` |
+| `CODE_BALANCE_UNAVAILABLE` | 83 | `balanceOf(to)` could not be read, so the cap cannot be verified |
+
+## Who is screened
+
+| Operation | Screened? |
+| --- | --- |
+| Transfer / `transferFrom` | **Receiver only** |
+| Mint (`from == address(0)`) | **Receiver** — a mint raises a balance like any transfer |
+| Burn (`to == address(0)`) | **No** — reducing supply cannot breach a maximum |
+| Sender | **Never** — sending tokens away only lowers a balance |
+| Spender on `transferFrom` | **Never** — the cap constrains who *ends up holding*, not who moved the tokens |
+
+An address already above the cap keeps its tokens and may still send them away. It simply cannot receive more
+until it is back under the cap. The same is true after the operator lowers the cap: existing balances are not
+clawed back.
+
+## Configuration
+
+| Constructor parameter | Description |
+| --- | --- |
+| `admin` / `owner` | Receives `DEFAULT_ADMIN_ROLE`, or ownership in the `Ownable2Step` variant |
+| `balanceToken_` | Token whose `balanceOf` is read; must be a contract answering `balanceOf` |
+| `maxBalance_` | Maximum balance per non-exempt address |
+
+### `maxBalance` has no magic value
+
+`0` means non-exempt addresses may hold **nothing**; it does **not** disable the rule. This is deliberate: a
+sentinel meaning "unlimited" would turn an operator's attempt to freeze holdings into the opposite. To lift the
+cap, set `type(uint256).max` or remove the rule from the engine.
+
+## Methods
+
+| Function | Role required | Description |
+| --- | --- | --- |
+| `setMaxBalance(uint256)` | `MAX_BALANCE_ROLE` / owner | Updates the cap |
+| `setBalanceToken(address)` | `MAX_BALANCE_ROLE` / owner | Updates the observed token |
+| `addExemptAddress(address)` | `MAX_BALANCE_ROLE` / owner | Exempts one address; reverts if already exempt or zero |
+| `removeExemptAddress(address)` | `MAX_BALANCE_ROLE` / owner | Removes one exemption; reverts if not exempt |
+| `addExemptAddresses(address[])` | `MAX_BALANCE_ROLE` / owner | Batch exempt; duplicates skipped, `address(0)` rejects the whole batch |
+| `removeExemptAddresses(address[])` | `MAX_BALANCE_ROLE` / owner | Batch remove; unknown entries skipped |
+| `isExemptAddress(address) → bool` | — | Whether an address may hold any amount |
+| `exemptAddressCount() → uint256` | — | Number of exempt addresses |
+| `remainingCapacity(address) → (uint8, uint256)` | — | Headroom before the cap, without simulating a transfer |
+
+Exemptions reuse the same `EnumerableSet` machinery as `RuleWhitelist` (`RuleAddressSetInternal`), so the batch
+semantics match the rest of the library: duplicates are skipped and counted, while `address(0)` is rejected on
+every add path including batches (invariant I-12).
+
+### Typical exemptions
+
+The exemption list exists for addresses that are not investor positions: the issuer's treasury, a custodian or
+omnibus account holding on behalf of many investors, a redemption or escrow contract, or a DEX pool. Exempting
+a custodian is the usual case — it holds for many people, so a per-holder cap is meaningless for it.
+
+> ⚠️ **An exempt address is an unlimited-holding address.** If the reason for the cap is a regulatory
+> concentration limit, exempting an account is a policy decision, not a technical convenience.
+
+## Zero-value transfers to a holder already over the cap
+
+A holder whose balance is **above** the cap — after the operator lowered it, or after a `forcedTransfer` — is
+rejected even for a transfer of `0`:
+
+| Receiver's balance | `value` | Code |
+| --- | --- | --- |
+| exactly at the cap | `0` | `0` (allowed) |
+| **above** the cap | `0` | **`82`** |
+
+Receiving nothing cannot breach a holding cap, so this is a false rejection. It also makes the token
+non-conformant to ERC-20 for that receiver, since the standard requires a zero-value transfer to be treated as
+a normal transfer.
+
+**Documented, not fixed, for `v0.5.0`.** The fix is to let `value == 0` pass regardless of the current balance.
+It is a behaviour change to shipped compliance logic and was recorded rather than applied late in the release;
+it is tracked together with the equivalent gap in the conditional-transfer rules, in
+[`RuleConditionalTransferLight.md`](./RuleConditionalTransferLight.md#zero-value-transfers-are-not-treated-as-no-ops).
+Exposure is limited: the receiver must already be over the cap, and nothing moves either way.
+
+## The read path never reverts
+
+The ERC-1404 / ERC-3643 views MUST NOT revert, so `balanceOf` is wrapped in `try/catch` and a failure yields
+`CODE_BALANCE_UNAVAILABLE` (fail-closed: the transfer is blocked rather than assumed safe). The token is
+validated at configuration — non-zero, has code, `balanceOf` callable — so reaching that branch means the token
+changed behaviour after configuration, for example a proxy upgraded to something that reverts, or a pausable
+implementation reverting while paused.
+
+Burns and exempt receivers are decided before any balance is read, so they keep working even while the token is
+unreadable.
+
+As with `RuleMaxTotalSupply` and `RuleChainlinkPoR`, this relies on the configured token still having code: a
+`try` call to a codeless address reverts *uncatchably*. The setter requires code, and EIP-6780 (Cancun) makes
+that permanent. This is a **deployment precondition** — a Cancun-or-later chain, which `foundry.toml` targets.
+
+## One instance per protected token
+
+The rule reads balances from the `balanceToken` it was configured with, never from the token that triggered the
+check, and behind a RuleEngine it cannot learn that identity. Adding one instance to two RuleEngines would
+evaluate both tokens against the first one's balances. Deploy a second instance instead. This is the same
+exposure as `RuleMaxTotalSupply` and `RuleChainlinkPoR`.
+
+## Usage scenario
+
+An issuer must keep any single investor below 5% of a 1,000,000-token issue. They deploy `RuleWhitelist` and
+`RuleMaxBalance(admin, cmtat, 50_000)` in the same RuleEngine, admit exactly one address per onboarded
+investor, and exempt the treasury address that holds the unsold allocation. An investor at 50,000 tokens can
+still sell, and can buy again once below the cap; a mint that would push them over is rejected with code `82`.
diff --git a/doc/technical/contracts/RuleMaxTotalSupply.md b/doc/technical/contracts/RuleMaxTotalSupply.md
new file mode 100644
index 00000000..83fdba2d
--- /dev/null
+++ b/doc/technical/contracts/RuleMaxTotalSupply.md
@@ -0,0 +1,138 @@
+# Rule Max Total Supply
+
+[TOC]
+
+This rule restricts minting so that the token's total supply never exceeds a configured maximum. Only mint operations (`from == address(0)`) are checked. Regular transfers between holders and burns are not affected.
+
+## Contract layout
+
+The rule is split in two, so cap management does not drag the rest of the rule along with it.
+
+| Contract | Holds | Depends on |
+| --- | --- | --- |
+| [`TotalSupplyCapManager`](../../../src/rules/validation/abstract/core/TotalSupplyCapManager.sol) | The observed token, the cap, their setters and the revert-free `totalSupply()` read | `TokenSupplyReader`, the invariant storage. **No constructor, no ERC-1404** |
+| [`RuleMaxTotalSupplyBase`](../../../src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol) | The constructor, the ERC-1404 / ERC-3643 surface and the logic mapping a breached cap to a restriction code | `RuleTransferValidation`, `TotalSupplyCapManager` |
+
+The manager declaring no constructor is the point: it exposes the `_set*` internals and leaves *when* they run
+to the inheritor. `RuleMaxTotalSupplyBase` calls them from its constructor; an upgradeable variant would call the
+same ones from an initializer, with no change to the manager. Nothing in the manager references a
+restriction-code interface either — it answers in booleans and token units — so a contract that only wants a
+revert-free view of remaining headroom can inherit it without implementing an ERC-1404 surface it does not
+need.
+
+Storage layout and the deployed ABI are **unchanged** by the split, verified per-slot from the compiled
+artifacts for both `RuleMaxTotalSupply` and `RuleMaxTotalSupplyOwnable2Step`.
+
+## Configuration
+
+### Constructor parameters
+
+| Parameter | Description |
+| --- | --- |
+| `admin` | Address granted `DEFAULT_ADMIN_ROLE` (implicitly holds all roles) |
+| `tokenContract_` | Address of the token contract; must be non-zero, must have code, and its `totalSupply()` must be callable |
+| `maxTotalSupply_` | Initial maximum total supply cap |
+
+### Post-deployment configuration
+
+Both the cap and the token contract address can be updated by the admin after deployment.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+### Flow with a CMTAT token
+
+The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a mint. Only mints (`from == address(0)`) are gated; transfers and burns pass.
+
+
+
+_Diagram source: doc/img/rule-max-total-supply-flow.puml._
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_MAX_TOTAL_SUPPLY_EXCEEDED` | 50 | Mint would cause total supply to exceed the maximum |
+| `CODE_SUPPLY_ORACLE_UNAVAILABLE` | 51 | `tokenContract.totalSupply()` reverted, or the token has lost its code |
+
+## Access Control
+
+The default admin is the address passed as `admin` in the constructor. It is granted `DEFAULT_ADMIN_ROLE`, which implicitly holds all roles. All privileged operations are gated on `DEFAULT_ADMIN_ROLE`.
+
+| Role | Description |
+| --- | --- |
+| `DEFAULT_ADMIN_ROLE` | May update the supply cap and token contract address |
+
+
+## Methods
+
+### `setMaxTotalSupply(uint256 newMaxTotalSupply)`
+
+Updates the maximum total supply cap. Restricted to `DEFAULT_ADMIN_ROLE`. Emits `MaxTotalSupplyUpdated`.
+
+### `setTokenContract(address newTokenContract)`
+
+Updates the reference to the token contract. Reverts if the address is zero, has no code, or its `totalSupply()` is not callable. Restricted to `DEFAULT_ADMIN_ROLE`. Emits `TokenContractUpdated`.
+
+### `maxTotalSupply() → uint256`
+
+Returns the current maximum total supply.
+
+### `tokenContract() → ITotalSupply`
+
+Returns the current token contract address.
+
+## Transfer restriction logic
+
+The rule only acts on mint operations (i.e. `from == address(0)`). It reads `tokenContract.totalSupply()` and rejects the mint if `totalSupply + value > maxTotalSupply`. Transfers and burns always pass.
+
+### Read-path safety
+
+`detectTransferRestriction` / `canTransfer` are ERC-1404 / ERC-3643 views that MUST NOT revert, so the supply read is guarded: a `tokenContract` that has lost its code, or whose `totalSupply()` reverts (a proxy upgraded to something broken, or a pausable implementation reverting while paused), yields `CODE_SUPPLY_ORACLE_UNAVAILABLE` (51) rather than propagating the failure. The comparison also uses remaining headroom (`value > maxTotalSupply - currentSupply`) instead of `currentSupply + value`, which could overflow.
+
+Configuration validates the token up front — non-zero, has code, and `totalSupply()` callable — so an unusable token fails loudly at setup instead of silently blocking every mint later. The code-length check is explicit rather than relying on the uncatchable extcodesize revert that the probe would incidentally produce.
+
+The trust placed in `tokenContract` is therefore narrower than it looks: it is trusted to report an **accurate** supply, which nothing on-chain can verify, but it is **not** trusted to stay callable.
+
+#### Deployment precondition: EIP-6780 (Cancun or later)
+
+**`try/catch` cannot contain a call to a codeless address.** This is the reason the code-length check lives at
+*configuration* rather than being left to the read path, and the mechanism is not the one usually quoted.
+
+`try/catch` catches a revert **raised by the callee**. It does not catch a failure that happens in *this*
+contract's frame while preparing or consuming the call. Two such failures apply here, and which one you get
+depends on the signature:
+
+| Call shape | What the compiler emits | Why `catch` never runs |
+| --- | --- | --- |
+| Returns data (`totalSupply() → uint256`) | Since **Solidity 0.8.10** the `EXTCODESIZE` check is *skipped*; the compiler relies on the ABI decoder instead | The `CALL` to a codeless account **succeeds** with 0 bytes of return data. The decoder then fails to read a `uint256` from nothing — in the caller's frame, *after* the call returned. Not a callee revert, so not catchable |
+| Returns nothing | The `EXTCODESIZE` check is still emitted, before the call | The revert happens before any external call is made. There is nothing for `catch` to attach to |
+
+So for `totalSupply()` the uncatchable revert comes from the **ABI decoder**, not from `extcodesize`. That is
+easy to confirm: point the rule at a contract that *has* code whose fallback succeeds and returns zero bytes.
+`EXTCODESIZE` passes, the `CALL` succeeds — and the view still reverts uncatchably.
+
+Two consequences follow:
+
+1. **A runtime code-length re-check would be pointless**, which is why there is none. `_setTokenContract`
+ requires code at configuration, and **EIP-6780** (Cancun) restricts `SELFDESTRUCT` to accounts created in
+ the same transaction, so a validated token cannot become codeless afterwards. On a chain *without* EIP-6780
+ this does not hold and the guard should be re-introduced (~100 gas per call site; the account is warm
+ either way, so it is not a full cold `EXTCODESIZE`).
+2. **Having code is necessary but not sufficient.** The guarantee is that the token returns a well-formed
+ `uint256`, not merely that it exists. A proxy upgraded to an implementation whose fallback returns empty
+ data keeps its code and still breaks the read path — the ABI decoder reverts and `CODE_SUPPLY_ORACLE_UNAVAILABLE`
+ is never reached. `try/catch` covers a token that *reverts*; it cannot cover one that returns nothing.
+ `tokenContract` is a trusted input for this reason, and pointing the rule at an untrusted proxy is outside
+ the model.
+
+## Usage scenario
+
+The operator deploys `RuleMaxTotalSupply` with `tokenContract = CMTAT_address` and `maxTotalSupply = 1_000_000`. The rule is registered in the `RuleEngine`. When the issuer mints 100,000 tokens and total supply is already 950,000, the mint is rejected with code 50. Transfers between existing holders continue unaffected.
diff --git a/doc/technical/RuleMintAllowance.md b/doc/technical/contracts/RuleMintAllowance.md
similarity index 76%
rename from doc/technical/RuleMintAllowance.md
rename to doc/technical/contracts/RuleMintAllowance.md
index 76d0cd25..1e3e018a 100644
--- a/doc/technical/RuleMintAllowance.md
+++ b/doc/technical/contracts/RuleMintAllowance.md
@@ -20,17 +20,17 @@ For that reason, `RuleMintAllowance` does not advertise the full ERC-3643 `IComp
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a mint. As an operation rule, it decrements the minter's allowance in the `transferred` callback after balances are updated.
-
+
_Diagram source: doc/img/rule-mint-allowance-flow.puml._
@@ -120,6 +120,31 @@ Burns (`to == address(0)`) are not tracked by this rule. Minters do not recover
An integrator that pre-flights a mint with `canTransfer` will see "allowed" even when the mint will revert on the quota. This is intentional: the 3-arg views cannot see the minter. Always pre-flight mints with the spender-aware pair, passing the minter as the spender and `address(0)` as `from`.
+#### The blind spot propagates to the RuleEngine and to the token
+
+The table above describes calls made **directly on the rule**. In a normal deployment nobody does that: the rule sits inside a `RuleEngine`, which sits in the token's compliance slot, and an integrator holds only the **token** address. Every level in that chain forwards the 3-argument call unchanged, so every level inherits the hardcoded `true`:
+
+| You call | Which reaches | Quota checked? |
+| --- | --- | --- |
+| `cmtat.detectTransferRestriction(address(0), to, value)` | `ruleEngine.detectTransferRestriction(...)` → each rule's 3-arg view | ❌ **No** |
+| `cmtat.canTransfer(address(0), to, value)` | same 3-arg chain | ❌ **No** |
+| `ruleEngine.canTransfer(address(0), to, value)` | `_detectTransferRestriction` → first non-zero code; this rule contributes `0` | ❌ **No** |
+| `cmtat.detectTransferRestrictionFrom(minter, address(0), to, value)` | `ruleEngine.detectTransferRestrictionFrom(...)` → this rule's 4-arg view | ✅ **Yes** |
+| `ruleEngine.canTransferFrom(minter, address(0), to, value)` | same 4-arg chain | ✅ **Yes** |
+
+`RuleEngineBase._detectTransferRestriction` walks its rules calling each one's **3-argument** `detectTransferRestriction` and returns the first non-zero code. This rule always returns `0`, so it contributes nothing to the aggregate — the engine-level answer is "allowed" no matter what the minter's quota is, for **every token that engine serves**. CMTAT's `ValidationModuleERC1404` then forwards its own ERC-1404 views to the engine, carrying the blind spot to the token's public API.
+
+> ⚠️ **If you hold only the token address**, the authoritative mint pre-flight is
+> `cmtat.detectTransferRestrictionFrom(minter, address(0), to, value)` — or `canTransferFrom` with the
+> same arguments. `cmtat.canTransfer` and `cmtat.detectTransferRestriction` will report a mint as
+> allowed that then reverts with `RuleMintAllowance_AllowanceExceeded`.
+
+This is pinned by `test_MA1_EngineAndTokenInheritTheHardcodedAllowedView_CurrentBehaviour` in
+[`test/ThreatModel/ThreatModelTests.t.sol`](../../../test/ThreatModel/ThreatModelTests.t.sol). Per the
+project convention, that test asserts behaviour the audit considers wrong: if the rule, the engine or
+the token is ever changed to close the gap, it fails, and the finding and documentation must be
+updated with it.
+
## Usage scenario
An issuer deploys `RuleMintAllowance` and grants `ALLOWANCE_OPERATOR_ROLE` to a compliance officer. The officer assigns `setMintAllowance(alice, 100_000e18)` — Alice may mint up to 100 000 tokens. Each `cmtat.mint(recipient, amount)` call by Alice reduces her quota. Once exhausted, further mints by Alice revert. The officer can call `increaseMintAllowance(alice, 50_000e18)` to extend Alice's quota or `setMintAllowance(alice, 0)` to revoke it entirely.
diff --git a/doc/technical/contracts/RuleReceiverWhitelist.md b/doc/technical/contracts/RuleReceiverWhitelist.md
new file mode 100644
index 00000000..9773e7a6
--- /dev/null
+++ b/doc/technical/contracts/RuleReceiverWhitelist.md
@@ -0,0 +1,112 @@
+# Rule Receiver Whitelist
+
+[TOC]
+
+`RuleReceiverWhitelist` is a whitelist that screens **only the receiver**, reproducing ERC-3643's eligibility rule as a CMTAT compliance rule. The sender and the spender are never checked.
+
+It sits between two existing rules:
+
+| Rule | Screens |
+| --- | --- |
+| [`RuleWhitelist`](./RuleWhitelist.md) | sender **and** receiver (spender optional) |
+| **`RuleReceiverWhitelist`** | **receiver only** |
+| [`RuleSpenderWhitelist`](./RuleSpenderWhitelist.md) | spender only |
+
+## Why receiver-only
+
+ERC-3643 mandates exactly one identity check, *"The receiver MUST be whitelisted on the Identity Registry and verified"*, and states that `transferFrom` "works the same way", that `mint` "only require[s] the receiver", and that `burn` "bypasses all checks on eligibility".
+
+That is not an oversight in the standard. **Screening the sender traps de-listed holders**: an investor whose eligibility lapses could neither receive nor send, stranding their position permanently. ERC-3643 checks only the receiver precisely so a lapsed investor can still exit to an eligible counterparty. This rule is the CMTAT-side expression of that decision, and the same reasoning `CLAUDE.md` records as non-negotiable for `RuleIdentityRegistry`.
+
+If you want both parties screened, that is a different policy; use `RuleWhitelist`.
+
+## Behaviour
+
+| Operation | Screened | Notes |
+| --- | --- | --- |
+| `transfer(from, to)` | `to` only | the sender is never checked |
+| `transferFrom(spender, from, to)` | `to` only | the spender is never checked either |
+| mint (`from == address(0)`) | `to`, like any other receiver | no `allowMint` flag — see below |
+| burn (`to == address(0)`) | nothing | always allowed |
+
+### Burn is exempted explicitly
+
+On a burn the receiver is `address(0)`, which **can never be listed**: the underlying address set rejects it, so `isAddressListed(address(0))` is always `false`. Without an explicit exemption every burn would be rejected. ERC-3643 says burn bypasses eligibility, so the exemption is the conformant behaviour rather than a convenience.
+
+### Mint has no opt-out flag
+
+Unlike `RuleWhitelist`, there is no `allowMint`/`allowBurn`. ERC-3643 gates minting on receiver eligibility alone: a mint to a listed address is allowed, a mint to an unlisted one is not. To cap or close issuance, compose with `RuleMaxTotalSupply` or `RuleChainlinkPoR` rather than adding a flag here.
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_ADDRESS_RECEIVER_NOT_WHITELISTED` | 81 | The receiver is not on the whitelist |
+
+The constant is named `RECEIVER` rather than `TO` so it does not collide with `RuleWhitelist`'s `CODE_ADDRESS_TO_NOT_WHITELISTED` (22) when a test contract inherits both invariant stores.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+## Configuration
+
+### Constructor parameters
+
+| Parameter | Description |
+| --- | --- |
+| `admin` / `owner` | `DEFAULT_ADMIN_ROLE` (AccessControl variant) or contract owner (Ownable2Step variant) |
+| `forwarderIrrevocable` | ERC-2771 forwarder for meta-transactions; `address(0)` to disable |
+
+Available as `RuleReceiverWhitelist` (AccessControl) and `RuleReceiverWhitelistOwnable2Step`.
+
+## Access Control
+
+| Role | Description |
+| --- | --- |
+| `ADDRESS_LIST_ADD_ROLE` | May call `addAddress` / `addAddresses` |
+| `ADDRESS_LIST_REMOVE_ROLE` | May call `removeAddress` / `removeAddresses` |
+
+Identical to the other address-set rules: the list machinery is `RuleAddressSet`, so batch operations skip duplicates, single operations revert on invalid input, and the zero address can never be listed.
+
+## Methods
+
+The full `IAddressList` surface is inherited from `RuleAddressSet`: `addAddress`, `addAddresses`, `removeAddress`, `removeAddresses`, `isAddressListed`, `areAddressesListed`, `listedAddressCount`. The rule advertises `IAddressList` via ERC-165.
+
+## Usage
+
+### Behind a RuleEngine, for a CMTAT token
+
+```solidity
+RuleReceiverWhitelist rule = new RuleReceiverWhitelist(admin, address(0));
+rule.addAddress(investor);
+ruleEngine.addRule(rule);
+cmtat.setRuleEngine(ruleEngine);
+```
+
+### As the compliance contract of an ERC-3643 token
+
+```solidity
+engine.setTokenSelfBindingApproval(address(token), true);
+token.setCompliance(address(engine)); // engine holds RuleReceiverWhitelist
+```
+
+Because the rule's semantics match the token's own, it composes with the identity registry without changing the token's behaviour; it narrows eligibility, never widens or redirects it.
+
+## Tests
+
+| File | Covers |
+| --- | --- |
+| `test/RuleReceiverWhitelist/RuleReceiverWhitelistUnit.t.sol` | Receiver screened; sender and spender explicitly not; mint screened on the receiver; burn always allowed; zero address never listable; write path; ERC-1404 surface; access control |
+| `test/RuleReceiverWhitelist/Ownable/RuleReceiverWhitelistOwnable2Step.t.sol` | Ownable2Step variant |
+| `test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol` | **Equivalence against the real vendored ERC-3643 token** |
+
+The parity suite is the one that matters for the conformance claim. It runs the rule in the compliance slot of the genuine `Token.sol` over the *same address set* the identity registry holds, and asserts the rule never changes the outcome — a de-listed holder still exits, an unlisted spender is not blocked, burn still works. A rule that screened the sender would break those while every unit test still passed. A final test confirms the rule is genuinely consulted (it blocks when its list is narrower than the registry's), so the parity results are not vacuous.
+
+Run it with `FOUNDRY_PROFILE=erc3643 forge test`; see `foundry.toml` for why that suite needs its own profile.
diff --git a/doc/technical/contracts/RuleSanctionsList.md b/doc/technical/contracts/RuleSanctionsList.md
new file mode 100644
index 00000000..0e653c68
--- /dev/null
+++ b/doc/technical/contracts/RuleSanctionsList.md
@@ -0,0 +1,123 @@
+# Rule SanctionsList
+
+[TOC]
+
+This rule uses the [Chainalysis](https://www.chainalysis.com/) on-chain oracle to block transfers involving sanctioned addresses. It checks the US, EU, and UN sanctions lists maintained by the oracle.
+
+## How to use
+
+Deploy the contract pointing to the Chainalysis oracle address. If either the sender (`from`), recipient (`to`), or spender (in `transferFrom`) is flagged by the oracle, the transfer is rejected.
+
+The oracle address and documentation are available here: [Chainalysis oracle for sanctions screening](https://go.chainalysis.com/chainalysis-oracle-docs.html).
+
+The oracle can be updated with `setSanctionListOracle` or disabled with `clearSanctionListOracle`. When no oracle is set (`address(0)`), all transfers pass this rule.
+
+## Schema
+
+### Graph
+
+
+
+### Inheritance
+
+
+
+### Flow with a CMTAT token
+
+The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer, including the Chainalysis oracle lookup and the no-oracle pass-through case.
+
+
+
+_Diagram source: doc/img/rule-sanctionslist-flow.puml._
+
+## Restriction codes
+
+| Constant | Code | Meaning |
+| --- | --- | --- |
+| `CODE_ADDRESS_FROM_IS_SANCTIONED` | 30 | Sender is sanctioned |
+| `CODE_ADDRESS_TO_IS_SANCTIONED` | 31 | Recipient is sanctioned |
+| `CODE_ADDRESS_SPENDER_IS_SANCTIONED` | 32 | Spender is sanctioned |
+
+## Who is screened
+
+Only **real participants** are sent to the oracle. The zero address is the ERC-20 mint/burn sentinel, not a wallet, so it is never queried:
+
+| Operation | `from` | `to` | `spender` |
+| --- | --- | --- | --- |
+| Transfer | screened | screened | — |
+| `transferFrom` | screened | screened | screened |
+| Mint (`from == address(0)`) | **not screened** | screened | screened — this is the **minter** |
+| Burn (`to == address(0)`) | screened | **not screened** | screened |
+
+The mint/burn exemptions cover the sentinel only, never a real address: a mint to a sanctioned recipient is still rejected with code `31`, and a burn from a sanctioned holder still with code `30`.
+
+This matters beyond tidiness. Forwarding `address(0)` to the oracle would delegate the rule's mint and burn behaviour to a third-party contract's handling of an input it has never been asked about — an oracle answering `true` for the zero address would block **all issuance and all redemption** on every token using this rule, and the restriction code would blame a "sanctioned sender" that is not an address. Chainalysis returns `false` today; the guard means the rule does not depend on that.
+
+The **minter is still screened**, as the `spender` on the 4-argument mint path — that is deliberate and unchanged (see `CLAUDE.md`, the mint/burn `spender` convention). Skipping the sentinel does not weaken it.
+
+Pinned by [`test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol`](../../../test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol), which configures an oracle that *does* sanction `address(0)` and asserts mint and burn still pass.
+
+Side effect on gas: a mint or burn now makes one oracle call instead of two.
+
+| Path | Before | After | Delta |
+| --- | --- | --- | --- |
+| Mint (`from == address(0)`) | 5 308 | 2 478 | **−2 830** |
+| Burn (`to == address(0)`) | 5 308 | 2 478 | **−2 830** |
+| Plain transfer | 3 309 | 3 405 | +96 |
+
+**Why the saving is 2 830 and not "half of one call".** Removing one of two oracle calls sounds like it should
+save about half the screening cost, and the first version of this note said ~900 gas on exactly that reasoning.
+It is wrong, because not all storage reads cost the same.
+
+Since **EIP-2929** (Berlin), reading a storage slot costs **2 100 gas the first time it is touched in a
+transaction** (*cold*) and **100 gas** on every subsequent read (*warm*). The oracle stores its list as
+`mapping(address => bool)`, so `isSanctioned(x)` is one `SLOAD` of the slot for `x`.
+
+Now compare what the two removed-versus-kept calls actually touch:
+
+- `isSanctioned(address(0))` — the sentinel. **Nothing else in the system ever reads that slot.** Not the
+ recipient check, not a previous transfer, not another rule. So on every mint it was **cold**: 2 100 gas for
+ the `SLOAD`, plus ~700 for the `STATICCALL` and ABI encode/decode around it. That is the ~2 830 that
+ disappeared.
+- `isSanctioned(alice)` on a plain transfer — real addresses are touched repeatedly by real activity, so these
+ slots are frequently already warm within a transaction, at 100 gas.
+
+That asymmetry inverted the usual ordering: **before the fix, a mint cost *more* than a transfer**
+(5 308 vs 3 309) while screening one *fewer* real participant. A mint has only one real party, yet it was the
+more expensive operation — the extra cost was entirely the cold lookup of an address that is not a wallet.
+That inversion is the clearest symptom of the bug this fix removes, and it is also what makes the naive
+"one call out of two ≈ 900 gas" estimate wrong by roughly 3×.
+
+**The cost side.** The two `!= address(0)` guards are evaluated on every plain transfer, where they are always
+true, adding **96 gas**. So the trade is: each transfer pays 96 so that each mint and burn saves 2 830. For any
+token that is not almost entirely issuance, that is strongly positive — and the gas was never the point. The
+reason for the change is that the rule no longer delegates its mint/burn behaviour to a third party's handling
+of a non-wallet.
+
+## Access Control
+
+The default admin is the address passed as `admin` in the constructor. It is granted `DEFAULT_ADMIN_ROLE`, which implicitly holds all roles.
+
+| Role | Description |
+| --- | --- |
+| `DEFAULT_ADMIN_ROLE` | Manages all roles; can call all privileged functions |
+| `SANCTIONLIST_ROLE` | May update or clear the oracle address (`setSanctionListOracle`, `clearSanctionListOracle`) |
+
+
+## Methods
+
+### `setSanctionListOracle(ISanctionsList sanctionContractOracle_)`
+
+Sets the Chainalysis oracle contract. Reverts if the address is zero. Restricted to `SANCTIONLIST_ROLE`.
+
+### `clearSanctionListOracle()`
+
+Removes the oracle (sets it to `address(0)`), effectively disabling sanctions checks. Restricted to `SANCTIONLIST_ROLE`.
+
+### `sanctionsList() → ISanctionsList`
+
+Returns the current oracle address. Returns `address(0)` if no oracle is set.
+
+## Usage scenario
+
+The operator deploys `RuleSanctionsList` with the Chainalysis oracle address and registers it in the `RuleEngine`. When the CMTAT token triggers a transfer, the rule calls `isSanctioned(from)` and `isSanctioned(to)` on the oracle. If either returns `true`, the transfer is rejected. The operator can later point to an updated oracle by calling `setSanctionListOracle`.
diff --git a/doc/technical/RuleSpenderWhitelist.md b/doc/technical/contracts/RuleSpenderWhitelist.md
similarity index 87%
rename from doc/technical/RuleSpenderWhitelist.md
rename to doc/technical/contracts/RuleSpenderWhitelist.md
index 045969d6..e7de51d5 100644
--- a/doc/technical/RuleSpenderWhitelist.md
+++ b/doc/technical/contracts/RuleSpenderWhitelist.md
@@ -17,17 +17,17 @@ This rule restricts only spender-initiated transfers (`transferFrom`): the spend
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer. Direct `transfer` calls always pass; only `transferFrom` spenders are gated.
-
+
_Diagram source: doc/img/rule-spender-whitelist-flow.puml._
diff --git a/doc/technical/RuleWhitelist.md b/doc/technical/contracts/RuleWhitelist.md
similarity index 94%
rename from doc/technical/RuleWhitelist.md
rename to doc/technical/contracts/RuleWhitelist.md
index ad117022..a52c25ed 100644
--- a/doc/technical/RuleWhitelist.md
+++ b/doc/technical/contracts/RuleWhitelist.md
@@ -23,17 +23,17 @@ When `checkSpender` is `true`, the spender in a `transferFrom` call must also be
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the rule participates when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer.
-
+
_Diagram source: doc/img/rule-whitelist-flow.puml._
diff --git a/doc/technical/RuleWhitelistWrapper.md b/doc/technical/contracts/RuleWhitelistWrapper.md
similarity index 74%
rename from doc/technical/RuleWhitelistWrapper.md
rename to doc/technical/contracts/RuleWhitelistWrapper.md
index e922004e..ded4baa0 100644
--- a/doc/technical/RuleWhitelistWrapper.md
+++ b/doc/technical/contracts/RuleWhitelistWrapper.md
@@ -8,23 +8,23 @@ This rule aggregates multiple child whitelist rules using OR logic. An address i
Each child rule must implement `IAddressList`. The wrapper iterates through all registered rules and returns `true` for an address as soon as one rule lists it. Iteration stops early once all required addresses are resolved.
-
+
## Schema
### Graph
-
+
### Inheritance
-
+
### Flow with a CMTAT token
The sequence below shows how the wrapper aggregates its child whitelist rules when a CMTAT token (with this rule configured in its RuleEngine) processes a transfer.
-
+
_Diagram source: doc/img/rule-whitelist-wrapper-flow.puml._
@@ -92,16 +92,23 @@ Returns the number of registered child rules.
`_detectTransferRestrictionForTargets` makes **one external `STATICCALL` per child rule**:
```solidity
+uint256 unresolved = targetsLength;
for (uint256 i = 0; i < rulesLength; ++i) {
bool[] memory isListed = IAddressList(rule(i)).areAddressesListed(targetAddress); // <- external call
- for (uint256 j = 0; j < targetAddress.length; ++j) {
- if (isListed[j]) { result[j] = true; }
+ for (uint256 j = 0; j < targetsLength; ++j) {
+ if (isListed[j] && !result[j]) { result[j] = true; --unresolved; }
}
// early exit: stop as soon as EVERY target address has been resolved
- ...
+ if (unresolved == 0) { break; }
}
```
+The early-exit test is a single comparison against a counter maintained as targets are resolved. It
+was previously a full rescan of `result` on every child rule; the counter form is equivalent and
+saves roughly **85 gas per child scanned** (~1% of the per-child cost). The external `STATICCALL`
+dominates, so the table below is essentially unchanged by it. Treat those figures as a marginally
+conservative upper bound.
+
**This is not only a `view` cost.** The wrapper's `transferred()` → `_detectTransferRestriction` path runs the same scan during **transfer execution**, so the gas is paid by the *transferring user*, on every transfer, for the life of the token.
### Measured cost
@@ -126,13 +133,13 @@ With `checkSpender = true` (3 target addresses instead of 2), the same 10-child
### Two things make the worst case the common case
-1. **The early exit only fires once *every* target address is resolved.** A transfer that is going to be **rejected** — because `from`, `to` (or `spender`) is in *no* child list — never resolves, and therefore scans **all N children**. The most expensive path is the failing one, and the user pays for it before the revert.
+1. **The early exit only fires once *every* target address is resolved.** A transfer that is going to be **rejected** (because `from`, `to` or `spender` is in *no* child list) never resolves, and therefore scans **all N children**. The most expensive path is the failing one, and the user pays for it before the revert.
2. **`checkSpender = true` adds a third address that must also be found** before the loop can break. It materially lowers the early-exit hit rate and pushes more transfers toward the full-N scan (≈ +35% at 10 children, per the table above).
### Operator guidance
-- **Keep the child list small — stay at or below the default cap of 10.** `addRule` reverts once `rulesCount() >= maxRules`, and `maxRules` defaults to `DEFAULT_MAX_RULES = 10`. At that cap the worst case is ~90k gas of scanning per transfer: significant, but safe.
-- **Raising `maxRules` is a decision with a permanent, per-transfer cost for every holder.** `setMaxRules` only rejects `0` — it accepts any other value. A rules manager who raises the cap to 100 makes the worst-case scan cost **~884k gas on every transfer**; at 200 it is ~1.77M. That is a tax on holders, not a broken token — transfers still fit in a block — but it is paid forever and cannot be refunded. Nothing untrusted can trigger this: only `RULES_MANAGEMENT_ROLE` (or the owner) can add child rules or raise the cap. **The size of the child list is the operator's responsibility.**
+- **Keep the child list small: stay at or below the default cap of 10.** `addRule` reverts once `rulesCount() >= maxRules`, and `maxRules` defaults to `DEFAULT_MAX_RULES = 10`. At that cap the worst case is ~90k gas of scanning per transfer: significant, but safe.
+- **Raising `maxRules` is a decision with a permanent, per-transfer cost for every holder.** `setMaxRules` only rejects `0`; it accepts any other value. A rules manager who raises the cap to 100 makes the worst-case scan cost **~884k gas on every transfer**; at 200 it is ~1.77M. That is a tax on holders, not a broken token — transfers still fit in a block — but it is paid forever and cannot be refunded. Nothing untrusted can trigger this: only `RULES_MANAGEMENT_ROLE` (or the owner) can add child rules or raise the cap. **The size of the child list is the operator's responsibility.**
- **Order children by expected hit rate.** Put the whitelist that resolves the most addresses first, so the early exit fires as early as possible. This is free and materially reduces the average cost.
- **Prefer fewer, larger child lists over many small ones.** The per-child overhead is an external call; the number of addresses inside a child does not affect the scan cost.
diff --git a/doc/technical/guides/DEPLOYMENT_SCRIPTS.md b/doc/technical/guides/DEPLOYMENT_SCRIPTS.md
new file mode 100644
index 00000000..10e9adc0
--- /dev/null
+++ b/doc/technical/guides/DEPLOYMENT_SCRIPTS.md
@@ -0,0 +1,264 @@
+# Deployment Scripts
+
+[TOC]
+
+This document covers the Foundry scripts in [`script/`](../../../script/): what each one deploys, the shape they
+all share, how to configure them, what you still have to do after running one, and the limitations worth
+knowing before you use them on a real chain.
+
+Every script produces a working CMTAT token with compliance rules attached and all admin rights held by a
+single address you choose. None of them is a turnkey issuance: each leaves configuration that only the
+operator can supply, listed per script under [After deployment](#after-deployment).
+
+## Inventory
+
+| Script | Deploys | Topology | Tests |
+| --- | --- | --- | --- |
+| `DeployCMTATWithWhitelist.s.sol` | CMTAT + `RuleWhitelist` | B (direct) | 7 |
+| `DeployCMTATWithBlacklist.s.sol` | CMTAT + `RuleBlacklist` | B (direct) | 6 |
+| `DeployCMTATWithBlacklistAndSanctionsList.s.sol` | CMTAT + `RuleEngine` + 2 rules | A (engine) | 18 |
+| `DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol` | CMTAT + `RuleEngine` + 3 rules | A (engine) | 19 |
+
+All four share [`script/base/CMTATDeploymentBase.sol`](../../../script/base/CMTATDeploymentBase.sol), which holds
+the token metadata, the environment configuration, and the address-logging helper.
+
+## The shape every script shares
+
+Each script exposes two entry points:
+
+| Function | Used by | Notes |
+| --- | --- | --- |
+| `deploy(...)` | tests, and other scripts | Plain function call. Takes `admin` and `deployer` explicitly. |
+| `run()` | `forge script` | Wraps `deploy` in `vm.startBroadcast()`, passing `msg.sender` for both. |
+
+### Why the deployer is a parameter
+
+`deploy()` takes both an `admin` and a `deployer`, which looks redundant since `run()` passes the same address
+for both. It is not redundant, and reading the deployer from `address(this)` instead is a bug that these
+scripts used to have.
+
+Under `forge script` the calls are made by the **broadcaster**, not by the script contract. Two independent
+things break if a script assumes otherwise:
+
+1. Foundry rejects `address(this)` inside a broadcast outright, with *"script contracts are ephemeral and
+ their addresses should not be relied upon"*. The script reverts during simulation.
+2. Even without that guard, `renounceRole` would fail. It is not `revokeRole`: its second argument is a
+ confirmation that the caller is that account (`AccessControl.sol:155`), so
+ `token.renounceRole(role, deployer)` only succeeds when `msg.sender == deployer`.
+
+Three of the four scripts read `address(this)` and could not deploy anything at all until this was fixed. The
+full write-up is [`CLAUDE_ANALYSIS_SCRIPT.md`](../../security/audits/tools/v0.5.0/CLAUDE_ANALYSIS_SCRIPT.md)
+S-1.
+
+### Temporary admin and hand-over
+
+The token and the RuleEngine are constructed with `deployer` as admin, because the wiring calls that follow
+are role-gated: `token.setRuleEngine(...)` needs admin on the token, `ruleEngine.addRule(...)` needs rights on
+the engine. The rules are constructed with `admin` directly, because nothing in the script ever configures
+them. `addRule` is a call on the engine, not on the rule.
+
+| Contract | Constructor admin | Deployer ever holds rights? | Handed over at the end? |
+| --- | --- | --- | --- |
+| CMTAT token | `deployer` | Yes | Yes |
+| `RuleEngine` | `deployer` | Yes | Yes |
+| Each rule | `admin` | No | Not needed |
+
+The hand-over is a grant followed by a renounce, in that order, because `AccessControl` has no atomic
+transfer and renouncing first would leave nobody able to grant:
+
+```solidity
+if (admin != deployer) {
+ ruleEngine.grantRole(ruleEngine.DEFAULT_ADMIN_ROLE(), admin);
+ ruleEngine.renounceRole(ruleEngine.DEFAULT_ADMIN_ROLE(), deployer);
+ token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+ token.renounceRole(token.DEFAULT_ADMIN_ROLE(), deployer);
+}
+```
+
+**The `if` is load-bearing.** In `run()` both arguments are `msg.sender`, so `admin == deployer`. Without the
+guard the grant would be a no-op and the renounce would then strip the only administrator, leaving a token
+nobody can ever configure or mint. Removing the guard and running the `admin == deployer` case was measured:
+the contract ends with no admin at all.
+
+Both CMTAT and `RuleEngine` grant exactly one role at construction, `DEFAULT_ADMIN_ROLE`, so renouncing it is
+complete. If someone later adds an explicit `grantRole(SOME_ROLE, deployer)` to the wiring, this block would
+not remove it and the current tests would not notice.
+
+### The two topologies
+
+The scripts deliberately use both integration models described in `CLAUDE.md`, and the choice changes what
+`msg.sender` is inside a rule:
+
+- **Topology B, direct binding** (`DeployCMTATWithWhitelist`, `DeployCMTATWithBlacklist`). The rule is passed
+ straight to `token.setRuleEngine(rule)`, with no engine in between, so inside the rule `msg.sender` is the
+ token. Fine for a single validation rule, and cheaper: one contract fewer and no engine hop per transfer.
+- **Topology A, RuleEngine** (the other two). Required as soon as there is more than one rule.
+
+This matters if you copy a script as a starting point. An operation rule such as
+`RuleConditionalTransferLightMultiToken` is direct-binding only, while `RuleMintAllowance` is not, so the two
+are not interchangeable. Each script states its topology in its NatSpec header.
+
+## Configuration
+
+Values come from the environment, with the previous hard-coded constants as defaults, so every script runs
+unconfigured. Defaults live in `CMTATDeploymentBase`.
+
+| Variable | Default | Applies to |
+| --- | --- | --- |
+| `CMTAT_NAME` | `CMTA Token` | all |
+| `CMTAT_SYMBOL` | `CMTAT` | all |
+| `CMTAT_DECIMALS` | `0` | all |
+| `CMTAT_TOKEN_ID` | `CMTAT_ISIN` | all |
+| `CMTAT_TERMS_NAME` | `Terms` | all |
+| `CMTAT_TERMS_URI` | `https://cmta.ch` | all |
+| `CMTAT_TERMS_HASH` | example document hash | all |
+| `CMTAT_INFORMATION` | `CMTAT_info` | all |
+| `CMTAT_FORWARDER` | `address(0)` | all |
+| `SANCTIONS_ORACLE` | `address(0)` | the two sanctions scripts |
+| `CMTAT_MAX_SUPPLY` | `1000000` | the max-total-supply script |
+
+A script needing different metadata overrides `_erc20Attributes()` or `_extraInformationAttributes()` rather
+than copying the block again.
+
+### Running
+
+```bash
+# local simulation, no key or RPC needed
+forge script script/DeployCMTATWithBlacklist.s.sol:DeployCMTATWithBlacklist
+
+# real deployment
+forge script script/DeployCMTATWithBlacklist.s.sol:DeployCMTATWithBlacklist \
+ --rpc-url --broadcast
+
+# with contract verification
+forge script ... --broadcast --verify --etherscan-api-key
+```
+
+Each script prints its deployed addresses with labels, so the run output doubles as a deployment record:
+
+```
+CMTAT token 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
+RuleEngine 0xBb2180ebd78ce97360503434eD37fcf4a1Df61c3
+RuleBlacklist 0x34A1D3fff3958843C43aD80F30b94c510645C316
+RuleSanctionsList 0x90193C961A926261B756D1E5bb255e67ff9498A1
+RuleMaxTotalSupply 0xA8452Ec99ce0C64f20701dB7dD3abDb607c00496
+```
+
+## The scripts
+
+### DeployCMTATWithWhitelist
+
+Signature: `deploy(admin, deployer, forwarder, checkSpender, allowMintBurn)`.
+
+Transfers are allowed only between whitelisted addresses (code `21` / `22`, and `23` for the spender when
+`checkSpender` is on). `run()` passes `checkSpender = false` and `allowMintBurn = true`.
+
+**`allowMintBurn` decides whether the token can be issued at all.** The whitelist screens the mint/burn
+sentinel `address(0)` like any other participant, so with `false` every mint is rejected with code `24`, even
+to a whitelisted investor. The script used to hard-code `false` and produced a token nobody could issue
+(`CLAUDE_ANALYSIS_SCRIPT.md` S-3). Both directions are pinned by tests. Recoverable either way with
+`setAllowMint` / `setAllowBurn`, which `RuleWhitelist` gates on `DEFAULT_ADMIN_ROLE`, so the admin the
+script hands over to can always recover.
+
+### DeployCMTATWithBlacklist
+
+Signature: `deploy(admin, deployer, forwarder)`.
+
+Blocks transfers involving a blacklisted sender, recipient or spender (codes `36` to `38`). The list starts
+empty, so a freshly deployed token allows every transfer until an address is added. Mint is open: `address(0)`
+is not on the list and `RuleBlacklist` has no mint flag.
+
+This is the inverse default of the whitelist script, and worth being deliberate about. A whitelist token is
+closed until you open it; a blacklist token is open until you close it.
+
+### DeployCMTATWithBlacklistAndSanctionsList
+
+Signature: `deploy(admin, deployer, forwarder, sanctionsOracle)`.
+
+Adds a `RuleEngine` holding `RuleBlacklist` then `RuleSanctionsList` (codes `30` to `32`). Rule order affects
+only *which* code a rejected transfer reports, since the engine returns the first non-zero one, not whether it
+is rejected. `testBlacklistTakesPriorityOverSanctions` pins that ordering.
+
+> ⚠️ **An unset oracle fails open.** With `sanctionsOracle == address(0)` the rule is registered, the engine
+> reports no error, and every transfer passes the sanctions check. The deployment looks complete and screening
+> is off. The oracle address is chain-specific, so there is no safe default.
+
+### DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply
+
+Signature: `deploy(admin, deployer, forwarder, sanctionsOracle, maxTotalSupply)`.
+
+The same, plus `RuleMaxTotalSupply` capping mints at `maxTotalSupply` (code `50`, or `51` when the supply
+cannot be read). Burning frees headroom for a later mint, which `testBurningFreesHeadroomForANewMint` pins.
+
+**Step order matters here.** `RuleMaxTotalSupply` validates its token at construction: non-zero, has code, and
+`totalSupply()` callable. It therefore cannot be deployed before the token exists, and passing a placeholder
+reverts with `RuleMaxTotalSupply_TokenIsNotAContract`.
+
+## After deployment
+
+Nothing below can be done by the script, because only the operator knows the values.
+
+| Script | Still required |
+| --- | --- |
+| Whitelist | Add every participant with `addAddress` / `addAddresses`. Until then transfers fail, including to the admin, who is not whitelisted by deployment. |
+| Blacklist | Nothing to make it work. Add addresses as needed. Review that an open-by-default token is what you want. |
+| Blacklist + sanctions | **Set the oracle** with `setSanctionListOracle`, unless `SANCTIONS_ORACLE` was set. |
+| The three-rule script | The same oracle step, plus confirm `CMTAT_MAX_SUPPLY` is the real cap and not the `1000000` default. |
+| All | Grant operational roles (minter, pauser, list managers) to whoever needs them. The admin implicitly holds all roles, so nothing is blocked, but relying on that means a single key does everything. |
+| All | Run a small end-to-end transfer before enabling production flows. |
+
+## Limitations
+
+**The deployed token is not upgradeable.** These scripts deploy `CMTATStandardStandalone`, not a proxy. There
+is no upgrade path afterwards; migration means deploying a new token.
+
+**A single address ends up holding everything.** The hand-over grants `DEFAULT_ADMIN_ROLE` to one `admin`, and
+under `run()` that is whoever broadcast the transaction. No multisig or timelock is wired up, and no role
+separation is applied. For production, hand over to a multisig and split roles afterwards.
+
+**Addresses are not deterministic.** Contracts are created with plain `new`, so addresses depend on the
+deployer's nonce. Re-running a script produces different addresses, and the same script on two chains does not
+give matching ones. There is no CREATE2 or salt support.
+
+**One `RuleMaxTotalSupply` instance protects one token, with no on-chain guard.** It reads `totalSupply()`
+from the `tokenContract` it was given, never from whichever token triggered the check, and behind a RuleEngine
+it cannot learn that identity. Adding this instance to a second RuleEngine caps both tokens against this one's
+supply. Deploy a second instance instead. The same applies to `RuleChainlinkPoR`, which no script deploys.
+
+**Sanctions screening fails open when unset**, as described above. This is the rule's documented behaviour,
+not a script defect, but the script default is the unsafe value because a chain-specific address has no
+sensible default.
+
+**`CMTAT_DECIMALS` defaults to `0`.** Correct for CMTA equity tokens per the CMTAT specification, and wrong
+for most other instruments. It is irrevocable after construction.
+
+**The metadata defaults are examples.** `CMTAT_ISIN`, the `https://cmta.ch` terms URI and the document hash
+are placeholders that will be written on-chain unless overridden. They are legal metadata, so review them.
+
+**No post-deployment assertion on-chain.** The scripts do not verify their own wiring after the fact. The
+tests cover it, but a partial failure on a live chain would not be caught by the script itself.
+
+## Testing
+
+Two layers, and they cover different things.
+
+**Unit tests**, in [`test/DeploymentScripts/`](../../../test/DeploymentScripts/), call `deploy()` directly and
+assert the wiring, the role hand-over, and the resulting compliance behaviour end to end: 50 tests across the
+four scripts, including that the admin owns everything and the deployer owns nothing.
+
+**A `forge script` dry run in CI**, one per script, in `.github/workflows/test.yml`.
+
+The second exists because the first structurally cannot cover the execution model. `deploy()` is a plain call
+with no broadcast context, so the guard that fires under `forge script` is never reached. Calling `run()` from
+a test instead does not work either:
+
+| Attempt | Result |
+| --- | --- |
+| `script.run()` from a test | Fails with an `AccessControlUnauthorizedAccount` that is an artefact of the harness, not the real error |
+| `vm.prank(DEFAULT_SENDER)` then `run()` | The prank is consumed by the preceding `new Script()`, a CREATE |
+| Construct first, then prank, then `run()` | `broadcasting and pranks are not compatible` |
+
+Foundry refuses to combine a prank with a broadcast, so no test can present itself to `run()` as the
+broadcaster. This was confirmed by reintroducing the bug into a fixed script: all six of that script's unit
+tests still passed, while the dry-run step failed. `forge script` is the only faithful harness, which is why
+it runs in CI.
diff --git a/doc/technical/INVARIANT_TESTS.md b/doc/technical/guides/INVARIANT_TESTS.md
similarity index 85%
rename from doc/technical/INVARIANT_TESTS.md
rename to doc/technical/guides/INVARIANT_TESTS.md
index 217e44c4..1c1cbd68 100644
--- a/doc/technical/INVARIANT_TESTS.md
+++ b/doc/technical/guides/INVARIANT_TESTS.md
@@ -2,11 +2,11 @@
[TOC]
-This document describes the **stateful invariant suite** in [`test/invariant/`](../../test/invariant/) — what each invariant asserts, why it matters, how the handlers are built, and how the suite was verified to actually catch bugs.
+This document describes the **stateful invariant suite** in [`test/invariant/`](../../../test/invariant/): what each invariant asserts, why it matters, how the handlers are built, and how the suite was verified to actually catch bugs.
Invariant tests differ from the unit and fuzz tests elsewhere in `test/`: instead of exercising a fixed call sequence, Foundry drives a **handler** contract with long, randomly-ordered sequences of calls and re-checks every `invariant_*` function after each step. They are the right tool for the two **stateful (operation) rules**, whose storage evolves across calls.
-Validation rules are read-only and hold no per-transfer state, so they have nothing to conserve across a call sequence — they are covered by unit and fuzz tests instead.
+Validation rules are read-only and hold no per-transfer state, so they have nothing to conserve across a call sequence, so unit and fuzz tests cover them instead.
---
@@ -39,7 +39,7 @@ Foundry cannot usefully fuzz a rule directly: `approveTransfer` needs `OPERATOR_
1. **Holds the required roles and is itself the bound entity.** The handler is passed to `bindToken(address(handler))` and granted the operator role, so `msg.sender` inside the rule is the handler and every call is authorized.
2. **Bounds the inputs.** A small actor set (3 addresses) and a small value range make the fuzzer *collide* on the same keys repeatedly, which is what actually exercises the accounting.
3. **Skips calls that would revert** (e.g. cancelling a non-existent approval), so `fail_on_revert = true` stays meaningful.
-4. **Maintains ghost variables** — an independent, off-chain-style mirror of what the rule's state *should* be. The invariant then compares the rule against the ghost.
+4. **Maintains ghost variables**: an independent, off-chain-style mirror of what the rule's state *should* be. The invariant then compares the rule against the ghost.
```
┌──────────────────────┐ randomly-ordered calls ┌──────────────────┐
@@ -58,9 +58,9 @@ Foundry cannot usefully fuzz a rule directly: `approveTransfer` needs `OPERATOR_
| File | Role |
|---|---|
-| [`test/invariant/ConditionalTransferHandler.sol`](../../test/invariant/ConditionalTransferHandler.sol) | Drives `RuleConditionalTransferLight`'s approval state machine |
-| [`test/invariant/MintAllowanceHandler.sol`](../../test/invariant/MintAllowanceHandler.sol) | Drives `RuleMintAllowance`'s quota accounting |
-| [`test/invariant/RuleInvariants.t.sol`](../../test/invariant/RuleInvariants.t.sol) | The two invariant test contracts and their `setUp` |
+| [`test/invariant/ConditionalTransferHandler.sol`](../../../test/invariant/ConditionalTransferHandler.sol) | Drives `RuleConditionalTransferLight`'s approval state machine |
+| [`test/invariant/MintAllowanceHandler.sol`](../../../test/invariant/MintAllowanceHandler.sol) | Drives `RuleMintAllowance`'s quota accounting |
+| [`test/invariant/RuleInvariants.t.sol`](../../../test/invariant/RuleInvariants.t.sol) | The two invariant test contracts and their `setUp` |
---
@@ -93,7 +93,7 @@ A weaker but independent bound: no tuple can ever hold more outstanding approval
The handler calls `executeMintOrBurn`, firing `transferred(address(0), to, v)` and `transferred(from, address(0), v)`, but **deliberately does not** count these in `totalExecuted`. If a mint or burn ever consumed an approval, `Σ approvalCounts` would drop while `totalExecuted` stayed put, and `invariant_approvalConservation` would break.
-So the mint/burn exemption is proved by the conservation invariant itself — no separate test needed.
+So the mint/burn exemption is proved by the conservation invariant itself, so no separate test is needed.
### 3.2 `RuleMintAllowance` — exact quota accounting
@@ -137,7 +137,7 @@ Re-run these yourself before trusting a change to either rule: if you mutate the
## 5. Coverage map
-Invariant IDs refer to [`THREAT_MODEL.md`](../../THREAT_MODEL.md) §8; verification status is tracked in [`RESULT.md`](../../RESULT.md).
+Invariant IDs and their verification status are recorded in [`CLAUDE_AUDIT.md`](../../security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md).
| Invariant (threat model) | Property | Covered by |
|---|---|---|
@@ -147,17 +147,17 @@ Invariant IDs refer to [`THREAT_MODEL.md`](../../THREAT_MODEL.md) §8; verificat
**Not covered by invariants (by design):**
-- `INV-1`, `INV-2`, `INV-3`, `INV-9` — properties of *stateless* view functions; unit and fuzz tests are the right tool (`test/ThreatModel/ThreatModelTests.t.sol`).
-- `INV-6` (`_transferHash` injectivity) — a pure function; covered by `testFuzz_HASH1_ApprovalBucketsAreDistinct`.
-- `INV-4`, `INV-11` (access control) — covered by the per-rule access-control suites.
-- `INV-10` (ERC-2771 binding identity) — currently holds by static reasoning; a live regression test is the open item **I-10a** in [`RULE_IMPROVEMENT.md`](../../RULE_IMPROVEMENT.md).
+- `INV-1`, `INV-2`, `INV-3`, `INV-9`: properties of *stateless* view functions; unit and fuzz tests are the right tool (`test/ThreatModel/ThreatModelTests.t.sol`).
+- `INV-6` (`_transferHash` injectivity): a pure function; covered by `testFuzz_HASH1_ApprovalBucketsAreDistinct`.
+- `INV-4`, `INV-11` (access control): covered by the per-rule access-control suites.
+- `INV-10` (ERC-2771 binding identity): currently holds by static reasoning; a live regression test remains an open item.
---
## 6. Adding a new invariant
1. Add the action to the relevant handler. **Guard it** so it can only make calls the rule accepts (`fail_on_revert = true` will otherwise fail the run).
-2. Update the ghost state *after* the rule call, in the same function. If the rule call reverts, the whole handler call reverts and the ghost rolls back with it — which is what keeps the mirror consistent.
+2. Update the ghost state *after* the rule call, in the same function. If the rule call reverts, the whole handler call reverts and the ghost rolls back with it, which is what keeps the mirror consistent.
3. Register the new selector in the `targetSelector(...)` array in `RuleInvariants.t.sol`, otherwise it will never be fuzzed.
4. Add the `invariant_*` function, with a message argument on each assertion so a failure is legible.
5. **Mutation-test it.** Inject the bug it is supposed to catch and confirm it fails.
diff --git a/doc/technical/RULE_SEMANTICS.md b/doc/technical/guides/RULE_SEMANTICS.md
similarity index 73%
rename from doc/technical/RULE_SEMANTICS.md
rename to doc/technical/guides/RULE_SEMANTICS.md
index f5ff71de..b66a9f0a 100644
--- a/doc/technical/RULE_SEMANTICS.md
+++ b/doc/technical/guides/RULE_SEMANTICS.md
@@ -18,10 +18,12 @@ Legend: ✅ screened / can block · ❌ not screened · ⚙️ conditional (see
|---|---|---|---|---|---|
| `RuleWhitelist` | ✅ must be listed | ✅ must be listed | ⚙️ only if `checkSpender` | ❌ exempt | ❌ exempt |
| `RuleWhitelistWrapper` | ✅ listed in ≥1 child | ✅ listed in ≥1 child | ⚙️ only if `checkSpender` | ❌ exempt | ❌ exempt |
+| `RuleReceiverWhitelist` | ❌ **never** [1b] | ✅ must be listed | ❌ never | ✅ receiver must be listed | ❌ exempt |
| `RuleSpenderWhitelist` | ❌ always allowed | ❌ always allowed | ✅ always (rule's purpose) | ❌ exempt | ❌ exempt |
| `RuleBlacklist` | ✅ blocks if listed | ✅ blocks if listed | ✅ blocks if listed | ✅ blocks listed minter [1] | ✅ blocks listed burner [1] |
| `RuleSanctionsList` | ✅ blocks if sanctioned | ✅ blocks if sanctioned | ✅ blocks if sanctioned | ✅ blocks sanctioned minter [1] | ✅ blocks sanctioned burner [1] |
| `RuleMaxTotalSupply` | ⚙️ mint only [2] | ❌ | ❌ ignored | ❌ caps supply, not minter | ❌ |
+| `RuleChainlinkPoR` | ⚙️ mint only [2b] | ❌ | ❌ ignored | ❌ caps supply, not minter | ❌ |
| `RuleIdentityRegistry` | ⚙️ only if `checkSender` [3] | ✅ **must be verified** (ERC-3643) [3] | ⚙️ only if `checkSpender` [3] | ❌ exempt [3] | ❌ exempt [3] |
| `RuleERC2980` | ⚙️ frozen-check only [4] | ✅ frozen-check + must be whitelisted | ✅ frozen-check | ✅ blocks frozen minter | ✅ blocks frozen burner |
| `RuleConditionalTransferLight` | ❌ per-tuple approval [5] | ❌ per-tuple approval [5] | ❌ spender ignored | ❌ exempt | ❌ exempt |
@@ -34,10 +36,12 @@ Legend: ✅ screened / can block · ❌ not screened · ⚙️ conditional (see
|---|---|---|---|---|
| `RuleWhitelist` | n/a (local address set) | ❌ | `canTransfer` / `canTransferFrom` | 21–25 |
| `RuleWhitelistWrapper` | empty wrapper ⇒ **all rejected** (fail-closed) | ❌ | `canTransfer` / `canTransferFrom` | 21–25 |
+| `RuleReceiverWhitelist` | n/a (local address set) | ❌ | `canTransfer` / `canTransferFrom` | 81 |
| `RuleSpenderWhitelist` | n/a (local address set) | ❌ | `canTransfer` (always ✓) / `canTransferFrom` | 66 |
| `RuleBlacklist` | n/a (local address set) | ❌ | `canTransfer` / `canTransferFrom` | 36–38 |
| `RuleSanctionsList` | oracle == 0 ⇒ **all allowed** (fail-open) [8] | ❌ | `canTransfer` / `canTransferFrom` | 30–32 |
-| `RuleMaxTotalSupply` | token contract required (non-zero) | ❌ | `canTransfer` / `canTransferFrom` [9] | 50 |
+| `RuleMaxTotalSupply` | token contract required (non-zero, has code, `totalSupply()` callable); a token that later reverts ⇒ **mints rejected** (fail-closed, code 51) | ❌ | `canTransfer` / `canTransferFrom` [9] | 50–51 |
+| `RuleChainlinkPoR` | feed required (non-zero contract); broken/stale feed ⇒ **mints rejected** (fail-closed) [2b] | ❌ | `canTransfer` / `canTransferFrom`, plus `maxBackedSupply()` | 75–79 |
| `RuleIdentityRegistry` | registry == 0 ⇒ **all allowed** (fail-open) [8] | ❌ | `canTransfer` / `canTransferFrom` | 55–57 |
| `RuleERC2980` | n/a (local lists) | ❌ | `canTransfer` / `canTransferFrom` | 60–65 |
| `RuleConditionalTransferLight` | n/a (needs `bindToken`) | ✅ consumes an approval | `canTransfer` / `canTransferFrom` | 46 |
@@ -48,18 +52,20 @@ Legend: ✅ screened / can block · ❌ not screened · ⚙️ conditional (see
## 3. Overload surface (ERC-7943 `tokenId` / `ITransferContext`)
-Not every rule exposes the same entrypoints. The ERC-7943 `tokenId` overloads and the `ITransferContext` struct entrypoints come from `RuleNFTAdapter`, and **only the rules that inherit it have them**. This is a deliberate design choice, not an oversight: `RuleMaxTotalSupply` caps a fungible supply, and the conditional-transfer / mint-allowance rules key on fungible amounts, so a `tokenId` dimension would be meaningless for them.
+Not every rule exposes the same entrypoints. The ERC-7943 `tokenId` overloads and the `ITransferContext` struct entrypoints come from `RuleNFTAdapter`, and **only the rules that inherit it have them**. The omission is deliberate: `RuleMaxTotalSupply` and `RuleChainlinkPoR` cap a fungible supply, and the conditional-transfer / mint-allowance rules key on fungible amounts, so a `tokenId` dimension would be meaningless for them.
| Rule | ERC-7943 `tokenId` overloads [12] | `transferred(FungibleTransferContext)` | `transferred(MultiTokenTransferContext)` |
|---|---|---|---|
| `RuleWhitelist` | ✅ | ✅ | ✅ |
| `RuleWhitelistWrapper` | ✅ | ✅ | ✅ |
+| `RuleReceiverWhitelist` | ✅ | ✅ | ✅ |
| `RuleBlacklist` | ✅ | ✅ | ✅ |
| `RuleSpenderWhitelist` | ✅ | ✅ | ✅ |
| `RuleSanctionsList` | ✅ | ✅ | ✅ |
| `RuleERC2980` | ✅ | ✅ | ✅ |
| `RuleIdentityRegistry` | ✅ | ✅ | ✅ |
| `RuleMaxTotalSupply` | ❌ | ❌ | ❌ |
+| `RuleChainlinkPoR` | ❌ | ❌ | ❌ |
| `RuleConditionalTransferLight` | ❌ | ✅ | ❌ |
| `RuleConditionalTransferLightMultiToken` | ❌ | ✅ | ❌ |
| `RuleMintAllowance` | ❌ | ❌ | ❌ |
@@ -75,8 +81,12 @@ The `tokenId` parameter is **always ignored** by the rules that accept it — `R
1. **Deny-lists intentionally screen the minter/burner.** `RuleBlacklist` and `RuleSanctionsList` do **not** exempt mint/burn from the spender check, so a blacklisted/sanctioned address cannot mint or burn. This is correct fail-closed behaviour for a deny-list (threat `BL-1`), the mirror image of the whitelist rules, which exempt mint/burn because the minter acts on its own authority rather than as a delegated spender.
+1b. **`RuleReceiverWhitelist` screens the receiver and nothing else** — the CMTAT-side expression of ERC-3643's eligibility rule. The sender and the spender are never checked, deliberately: screening the sender **traps de-listed holders**, and ERC-3643 checks only the receiver precisely so a lapsed investor can still exit. Mint is screened on the receiver like any other transfer (no `allowMint` flag); burn is exempt, because `address(0)` can never be listed and would otherwise be rejected on every burn. Equivalence with the standard is pinned against the real vendored token in `test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol`. Use `RuleWhitelist` when you want both parties screened. See [RuleReceiverWhitelist.md](../contracts/RuleReceiverWhitelist.md).
+
2. **`RuleMaxTotalSupply` only acts on mints.** `_detectTransferRestriction` returns `TRANSFER_OK` unless `from == address(0)`; it caps *total supply*, so the "screened party" is the mint operation, not any address. The spender is ignored on every path.
+2b. **`RuleChainlinkPoR` only acts on mints, and fails closed for mints only.** Like `RuleMaxTotalSupply` it returns `TRANSFER_OK` unless `from == address(0)`, and ignores the spender. The cap is not static but read live from a Chainlink Proof of Reserve feed: `totalSupply + value` must stay within the reported reserves, scaled from the feed's decimals to the token's. The limit equals the reserves exactly — there is no margin parameter. Feed failures are reported by kind: code `79` when no usable response could be obtained (`decimals()` or `latestRoundData()` reverted, or the feed reports more than `MAX_FEED_DECIMALS`), code `77` when a round was returned but is unusable (negative reserve, incomplete round), and code `76` when the answer is older than `maxStalenessSeconds`. A `tokenContract` whose `totalSupply()` reverts yields code `78`. **One instance protects one token:** the rule reads `totalSupply()` from its configured `tokenContract`, not from the token that triggered the check, so sharing an instance across two RuleEngines silently evaluates both against the first token's supply and feed (same exposure as `RuleMaxTotalSupply`; see [One instance per protected token](../contracts/RuleChainlinkPoR.md#one-instance-per-protected-token)). Both block **minting only** — transfers and burns still pass, so a lapsed feed never traps holders. The read path is guarded (`code.length` check, `try/catch`, saturating arithmetic) so it can return these codes without reverting. See [RuleChainlinkPoR.md](../contracts/RuleChainlinkPoR.md).
+
3. **`RuleIdentityRegistry` is ERC-3643 conformant: only the RECEIVER is verified** (improvement I-1, finding **F-1** fixed). The spec mandates exactly one check — *"The receiver MUST be whitelisted on the Identity Registry and verified"* — and explicitly states that `transferFrom` "works the same way", that `mint` "only require[s] the receiver", and that `burn` "bypasses all checks on eligibility". The sender, spender and minter are therefore **not** screened by default. Checking the sender would **trap de-listed holders**: ERC-3643 screens only the receiver precisely so an investor whose identity lapses can still exit their position by sending to a verified counterparty. Stricter screening is available as an explicit opt-in via `checkSender` / `checkSpender` (both default `false`); mint and burn stay exempt from the spender check even when `checkSpender` is on.
4. **`RuleERC2980` does not require the sender to be whitelisted** — only that the sender is *not frozen*; only the recipient must be whitelisted (threat `E29-1`, ERC-2980 semantics). Note also that freezing `address(0)` blocks all mints and that burns require `address(0)` to be whitelisted via the `allowBurn` constructor flag (threat `E29-2`).
@@ -91,12 +101,12 @@ The `tokenId` parameter is **always ignored** by the rules that accept it — `R
9. **`RuleMaxTotalSupply` views are overflow-safe** (finding **F-2**, fixed): `detectTransferRestriction` / `canTransfer` return code `50` instead of reverting when `currentSupply + value` would overflow.
-10. **`RuleConditionalTransferLightMultiToken` is direct-binding-only, and its `detectTransferRestriction` depends on `msg.sender`.** Approvals are recorded under the `token` argument but *consumed* under `msg.sender`, so the rule **must be bound directly to each token** (`CMTAT.setRuleEngine(rule)`) and **must not be added to a `RuleEngine`** — behind an engine it either reverts or silently loses all per-token isolation (finding **F-4**; full case analysis in [RuleConditionalTransferLightMultiToken.md](./RuleConditionalTransferLightMultiToken.md#deployment-topology--why-a-ruleengine-does-not-work)). For the same reason `detectTransferRestriction` / `canTransfer` derive the token key from the caller, so an off-chain `eth_call` from a non-bound address always reads "not approved" (code 46) even for an approved transfer (threat `CTL-4`, finding **F-8**). Use the caller-explicit **`detectTransferRestrictionForToken(token, …)`** / **`canTransferForToken(token, …)`** views for pre-flight — they take the token as a parameter and give every caller the real answer.
+10. **`RuleConditionalTransferLightMultiToken` is direct-binding-only, and its `detectTransferRestriction` depends on `msg.sender`.** Approvals are recorded under the `token` argument but *consumed* under `msg.sender`, so the rule **must be bound directly to each token** (`CMTAT.setRuleEngine(rule)`) and **must not be added to a `RuleEngine`** — behind an engine it either reverts or silently loses all per-token isolation (finding **F-4**; full case analysis in [RuleConditionalTransferLightMultiToken.md](../contracts/RuleConditionalTransferLightMultiToken.md#deployment-topology--why-a-ruleengine-does-not-work)). For the same reason `detectTransferRestriction` / `canTransfer` derive the token key from the caller, so an off-chain `eth_call` from a non-bound address always reads "not approved" (code 46) even for an approved transfer (threat `CTL-4`, finding **F-8**). Use the caller-explicit **`detectTransferRestrictionForToken(token, …)`** / **`canTransferForToken(token, …)`** views for pre-flight — they take the token as a parameter and give every caller the real answer.
-11. **`RuleMintAllowance.canTransfer` / `detectTransferRestriction` are NOT authoritative** (finding **F-7**): they are hardcoded to "allowed" because the 3-arg signature has no minter identity. Pre-flight a mint with `canTransferFrom(minter, address(0), to, value)`. See [RuleMintAllowance.md](./RuleMintAllowance.md#eligibility-views-which-one-is-authoritative).
+11. **`RuleMintAllowance.canTransfer` / `detectTransferRestriction` are NOT authoritative** (finding **F-7**): they are hardcoded to "allowed" because the 3-arg signature has no minter identity. Pre-flight a mint with `canTransferFrom(minter, address(0), to, value)`. See [RuleMintAllowance.md](../contracts/RuleMintAllowance.md#eligibility-views-which-one-is-authoritative).
12. **The ERC-7943 `tokenId` overloads** are `detectTransferRestriction(from,to,tokenId,value)`, `detectTransferRestrictionFrom(spender,from,to,tokenId,value)`, `canTransfer(from,to,tokenId,amount)`, `canTransferFrom(spender,from,to,tokenId,value)`, `transferred(from,to,tokenId,value)` and `transferred(spender,from,to,tokenId,value)` — all supplied by `RuleNFTAdapter`. Per ERC-7943, `amount`/`value` MUST be `1` for ERC-721. The rules ignore `tokenId` entirely; it exists so an ERC-721/ERC-1155 token can call the same compliance rule without a shim.
---
-See [`../../RESULT.md`](../../RESULT.md) for the findings referenced above and [`../../THREAT_MODEL.md`](../../THREAT_MODEL.md) for the threat IDs.
+See [`CLAUDE_AUDIT.md`](../../security/audits/tools/v0.4.0/claude-audit/CLAUDE_AUDIT.md) for the findings referenced above.
diff --git a/foundry.toml b/foundry.toml
index c3ab0068..60af3ad9 100644
--- a/foundry.toml
+++ b/foundry.toml
@@ -1,11 +1,41 @@
[profile.default]
-solc = "0.8.34"
+solc = "0.8.36"
src = 'src'
out = 'out'
libs = ['lib']
optimizer = true
optimizer_runs = 200
evm_version = 'prague'
+# ERC-3643's Token.sol pins `pragma solidity 0.8.30` exactly, which cannot share a compilation unit
+# with solc 0.8.36. Tests that deploy the real vendored token therefore live in their own directory
+# and are built by the `erc3643` profile below. Run them with:
+# FOUNDRY_PROFILE=erc3643 forge test
+skip = ['test/ERC3643Real/**']
+
+# Builds the whole project at solc 0.8.30 so the vendored ERC-3643 `Token.sol` compiles alongside
+# our own contracts (which are `^0.8.20`, so 0.8.30 satisfies them). Nothing else changes.
+[profile.erc3643]
+solc = "0.8.30"
+src = 'src'
+out = 'out-erc3643'
+libs = ['lib']
+optimizer = true
+optimizer_runs = 200
+evm_version = 'prague'
+# Profiles inherit unspecified keys from [profile.default], so the default's `skip` must be
+# cleared here or this profile would skip the very tests it exists to run.
+skip = []
+match_path = 'test/ERC3643Real/*'
+# ERC-3643 imports `@onchain-id/solidity`, an npm package rather than a submodule, so it is not
+# vendored. `test/utils/onchainid/` holds minimal IIdentity / IClaimIssuer stubs, wired in by a
+# CONTEXT-SCOPED remapping (the `lib/ERC-3643/:` prefix) so they apply to the ERC-3643 build only.
+#
+# It lives here rather than in `remappings.txt` on purpose. `forge remappings` prints that file for
+# every profile, and `hardhat-foundry` runs exactly that command and rejects any line containing a
+# `:` with "remapping contexts are not allowed" -- which broke `npx hardhat test` in CI. Declared as
+# profile config it is applied only when this profile is selected, so the default profile Hardhat
+# sees stays context-free. Do not move it back.
+remappings = ['lib/ERC-3643/:@onchain-id/solidity/contracts/=test/utils/onchainid/']
[invariant]
runs = 64
diff --git a/hardhat.config.js b/hardhat.config.js
index 38cd1543..302db33a 100644
--- a/hardhat.config.js
+++ b/hardhat.config.js
@@ -3,7 +3,7 @@ require("@nomicfoundation/hardhat-toolbox");
require("@nomicfoundation/hardhat-foundry");
module.exports = {
solidity: {
- version: "0.8.34",
+ version: "0.8.36",
settings: {
optimizer: {
enabled: true,
diff --git a/lib/CMTAT b/lib/CMTAT
index 580d4776..658672f1 160000
--- a/lib/CMTAT
+++ b/lib/CMTAT
@@ -1 +1 @@
-Subproject commit 580d4776e4cbb857b2da7d83fd79144ae7e47557
+Subproject commit 658672f190d56d3f61663a7d6d51962b8980df70
diff --git a/lib/ERC-3643 b/lib/ERC-3643
new file mode 160000
index 00000000..a7087583
--- /dev/null
+++ b/lib/ERC-3643
@@ -0,0 +1 @@
+Subproject commit a708758313f7589c6709c29d003f73b6db24663a
diff --git a/lib/RuleEngine b/lib/RuleEngine
index 66fcf2aa..ab9def2f 160000
--- a/lib/RuleEngine
+++ b/lib/RuleEngine
@@ -1 +1 @@
-Subproject commit 66fcf2aafebd1f9d9de8a81dec92b88da071c9b3
+Subproject commit ab9def2f19ae71af304127f42d20d9831cad1a2b
diff --git a/lib/chainlink-ace b/lib/chainlink-ace
new file mode 160000
index 00000000..60f04505
--- /dev/null
+++ b/lib/chainlink-ace
@@ -0,0 +1 @@
+Subproject commit 60f04505f440c662351fe934e580f49c1821decc
diff --git a/lib/chainlink-doc b/lib/chainlink-doc
new file mode 160000
index 00000000..4cd1d61a
--- /dev/null
+++ b/lib/chainlink-doc
@@ -0,0 +1 @@
+Subproject commit 4cd1d61a0a6a8cd195222900f23abaeeb1771f40
diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts
index 5fd1781b..cab19933 160000
--- a/lib/openzeppelin-contracts
+++ b/lib/openzeppelin-contracts
@@ -1 +1 @@
-Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256
+Subproject commit cab19933c33c2ad1d4c7a84864a3601dddfd16f3
diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable
index 7bf4727a..14f52c54 160000
--- a/lib/openzeppelin-contracts-upgradeable
+++ b/lib/openzeppelin-contracts-upgradeable
@@ -1 +1 @@
-Subproject commit 7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf
+Subproject commit 14f52c54d3a1eefbda3d4071efba24d3c1e07e8a
diff --git a/package-lock.json b/package-lock.json
index 94696cd0..8b91f38d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,9 +14,9 @@
}
},
"node_modules/@adraffy/ens-normalize": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
- "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
+ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
"dev": true,
"license": "MIT"
},
@@ -631,7 +631,6 @@
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@@ -650,7 +649,6 @@
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -664,7 +662,6 @@
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -677,8 +674,7 @@
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
@@ -686,7 +682,6 @@
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@@ -705,7 +700,6 @@
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^6.2.2"
},
@@ -722,7 +716,6 @@
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@@ -1266,15 +1259,14 @@
"dev": true,
"license": "MIT",
"optional": true,
- "peer": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@puppeteer/browsers": {
- "version": "2.13.0",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
- "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",
+ "version": "2.13.2",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz",
+ "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1294,9 +1286,9 @@
}
},
"node_modules/@puppeteer/browsers/node_modules/semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -1307,9 +1299,9 @@
}
},
"node_modules/@puppeteer/browsers/node_modules/yargs": {
- "version": "17.7.2",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
- "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1999,6 +1991,7 @@
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@@ -2122,14 +2115,15 @@
}
},
"node_modules/axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -2276,9 +2270,9 @@
}
},
"node_modules/basic-ftp": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz",
- "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2291,6 +2285,7 @@
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
},
@@ -2370,9 +2365,9 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
- "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2385,6 +2380,7 @@
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -2690,9 +2686,9 @@
}
},
"node_modules/cheerio/node_modules/undici": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
- "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3189,7 +3185,6 @@
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -3205,7 +3200,6 @@
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -3216,7 +3210,6 @@
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -3230,7 +3223,6 @@
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -3241,7 +3233,6 @@
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -3486,9 +3477,9 @@
}
},
"node_modules/devtools-protocol": {
- "version": "0.0.1595872",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz",
- "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==",
+ "version": "0.0.1608973",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",
+ "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"dev": true,
"license": "BSD-3-Clause"
},
@@ -3498,6 +3489,7 @@
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
"dev": true,
"license": "BSD-3-Clause",
+ "peer": true,
"engines": {
"node": ">=0.3.1"
}
@@ -3614,8 +3606,7 @@
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/elliptic": {
"version": "6.6.1",
@@ -4003,9 +3994,9 @@
}
},
"node_modules/ethers": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
- "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
+ "version": "6.17.0",
+ "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz",
+ "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==",
"dev": true,
"funding": [
{
@@ -4019,13 +4010,13 @@
],
"license": "MIT",
"dependencies": {
- "@adraffy/ens-normalize": "1.10.1",
+ "@adraffy/ens-normalize": "1.11.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
- "ws": "8.17.1"
+ "ws": "8.21.0"
},
"engines": {
"node": ">=14.0.0"
@@ -4049,9 +4040,9 @@
"license": "MIT"
},
"node_modules/ethers/node_modules/ws": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
- "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -4208,9 +4199,9 @@
"peer": true
},
"node_modules/fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -4265,6 +4256,7 @@
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -4340,7 +4332,6 @@
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
- "peer": true,
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
@@ -4358,7 +4349,6 @@
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=14"
},
@@ -4367,17 +4357,17 @@
}
},
"node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -4410,7 +4400,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -4423,6 +4414,7 @@
"os": [
"darwin"
],
+ "peer": true,
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
@@ -4628,12 +4620,35 @@
"node": ">=4"
}
},
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
@@ -4693,9 +4708,9 @@
}
},
"node_modules/globby/node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -4808,9 +4823,9 @@
}
},
"node_modules/hardhat": {
- "version": "2.28.6",
- "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.28.6.tgz",
- "integrity": "sha512-zQze7qe+8ltwHvhX5NQ8sN1N37WWZGw8L63y+2XcPxGwAjc/SMF829z3NS6o1krX0sryhAsVBK/xrwUqlsot4Q==",
+ "version": "2.29.0",
+ "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.29.0.tgz",
+ "integrity": "sha512-tsj5mCSjDCFOhGfBl4vwqDEcwdlES9VUzRWfdrwvEVhus6D8W6u+WfUKRLLwFhKGS/8lKPoXGsjYWPXl3CCpOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4839,7 +4854,7 @@
"lodash": "^4.17.11",
"micro-eth-signer": "^0.14.0",
"mnemonist": "^0.38.0",
- "mocha": "^10.0.0",
+ "mocha": "^11.1.0",
"p-map": "^4.0.0",
"picocolors": "^1.1.0",
"raw-body": "^2.4.1",
@@ -4982,46 +4997,6 @@
"@scure/bip39": "1.3.0"
}
},
- "node_modules/hardhat-gas-reporter/node_modules/glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
- "dev": true,
- "license": "ISC",
- "peer": true,
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/hardhat-gas-reporter/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "dev": true,
- "license": "ISC",
- "peer": true,
- "dependencies": {
- "brace-expansion": "^2.0.2"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/hardhat/node_modules/@noble/hashes": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.2.0.tgz",
@@ -5080,6 +5055,16 @@
"@scure/base": "~1.1.0"
}
},
+ "node_modules/hardhat/node_modules/diff": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
+ "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.3.1"
+ }
+ },
"node_modules/hardhat/node_modules/ethereum-cryptography": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz",
@@ -5151,6 +5136,43 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/hardhat/node_modules/mocha": {
+ "version": "11.8.0",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz",
+ "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browser-stdout": "^1.3.1",
+ "chokidar": "^4.0.1",
+ "debug": "^4.3.5",
+ "diff": "^7.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-up": "^5.0.0",
+ "glob": "^10.4.5",
+ "he": "^1.2.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "log-symbols": "^4.1.0",
+ "minimatch": "^9.0.5",
+ "ms": "^2.1.3",
+ "picocolors": "^1.1.1",
+ "serialize-javascript": "^6.0.2",
+ "strip-json-comments": "^3.1.1",
+ "supports-color": "^8.1.1",
+ "workerpool": "^9.2.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1",
+ "yargs-unparser": "^2.0.0"
+ },
+ "bin": {
+ "_mocha": "bin/_mocha",
+ "mocha": "bin/mocha.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
"node_modules/hardhat/node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -5183,6 +5205,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/hardhat/node_modules/supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
+ }
+ },
"node_modules/hardhat/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -5193,6 +5231,42 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/hardhat/node_modules/workerpool": {
+ "version": "9.3.4",
+ "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz",
+ "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/hardhat/node_modules/yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/hardhat/node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -5355,9 +5429,9 @@
}
},
"node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5524,9 +5598,9 @@
}
},
"node_modules/immutable": {
- "version": "4.3.8",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz",
- "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==",
+ "version": "4.3.9",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
+ "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
"dev": true,
"license": "MIT"
},
@@ -5562,6 +5636,7 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dev": true,
+ "peer": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
@@ -5603,9 +5678,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5625,6 +5700,7 @@
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"binary-extensions": "^2.0.0"
},
@@ -5652,6 +5728,7 @@
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5672,6 +5749,7 @@
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"is-extglob": "^2.1.1"
},
@@ -5697,10 +5775,21 @@
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.12.0"
}
},
+ "node_modules/is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
@@ -5773,8 +5862,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/isows": {
"version": "1.0.7",
@@ -5799,7 +5887,6 @@
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
- "peer": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@@ -5833,10 +5920,20 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -6066,8 +6163,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
- "license": "ISC",
- "peer": true
+ "license": "ISC"
},
"node_modules/lru-queue": {
"version": "0.1.0",
@@ -6275,6 +6371,22 @@
"integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
"dev": true
},
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -6292,7 +6404,6 @@
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
- "peer": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -6334,6 +6445,7 @@
"integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-colors": "^4.1.3",
"browser-stdout": "^1.3.1",
@@ -6370,6 +6482,7 @@
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -6395,6 +6508,7 @@
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -6413,6 +6527,7 @@
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -6433,6 +6548,7 @@
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-locate": "^5.0.0"
},
@@ -6449,6 +6565,7 @@
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -6462,6 +6579,7 @@
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"yocto-queue": "^0.1.0"
},
@@ -6478,6 +6596,7 @@
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"p-limit": "^3.0.2"
},
@@ -6494,6 +6613,7 @@
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
@@ -6507,6 +6627,7 @@
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -6631,6 +6752,7 @@
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -6726,9 +6848,9 @@
}
},
"node_modules/ox": {
- "version": "0.14.15",
- "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.15.tgz",
- "integrity": "sha512-3TubCmbKen/cuZQzX0qDbOS5lojjdSZ90lqKxWIDWd5siuJ0IJBaTXMYs8eMPLcraqnOwGZazz3apHPGiRCkGQ==",
+ "version": "0.14.33",
+ "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz",
+ "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==",
"dev": true,
"funding": [
{
@@ -6757,14 +6879,6 @@
}
}
},
- "node_modules/ox/node_modules/@adraffy/ens-normalize": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
- "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/ox/node_modules/@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
@@ -6875,8 +6989,7 @@
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
- "license": "BlueOak-1.0.0",
- "peer": true
+ "license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
"version": "1.0.1",
@@ -6995,7 +7108,6 @@
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
- "peer": true,
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@@ -7090,6 +7202,7 @@
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8.6"
},
@@ -7262,19 +7375,19 @@
}
},
"node_modules/puppeteer": {
- "version": "24.41.0",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.41.0.tgz",
- "integrity": "sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==",
+ "version": "24.43.1",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.1.tgz",
+ "integrity": "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@puppeteer/browsers": "2.13.0",
+ "@puppeteer/browsers": "2.13.2",
"chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1595872",
- "puppeteer-core": "24.41.0",
- "typed-query-selector": "^2.12.1"
+ "devtools-protocol": "0.0.1608973",
+ "puppeteer-core": "24.43.1",
+ "typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/cjs/puppeteer/node/cli.js"
@@ -7284,28 +7397,28 @@
}
},
"node_modules/puppeteer-core": {
- "version": "24.41.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.41.0.tgz",
- "integrity": "sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==",
+ "version": "24.43.1",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz",
+ "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@puppeteer/browsers": "2.13.0",
+ "@puppeteer/browsers": "2.13.2",
"chromium-bidi": "14.0.0",
"debug": "^4.4.3",
- "devtools-protocol": "0.0.1595872",
- "typed-query-selector": "^2.12.1",
+ "devtools-protocol": "0.0.1608973",
+ "typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.1",
- "ws": "^8.19.0"
+ "ws": "^8.20.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/puppeteer-core/node_modules/ws": {
- "version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -7429,9 +7542,9 @@
}
},
"node_modules/recursive-readdir/node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -7637,9 +7750,9 @@
}
},
"node_modules/sc-istanbul/node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -7679,9 +7792,9 @@
}
},
"node_modules/sc-istanbul/node_modules/js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
+ "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -7753,9 +7866,9 @@
"peer": true
},
"node_modules/secp256k1": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.4.tgz",
- "integrity": "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.5.tgz",
+ "integrity": "sha512-SQZi5+/uiJIFPYbeRrVuu77Sr3bFOTq0oCQs67CqYwdmg0lhnqi/8djSWhzNO3GKGOqxBYCdx8zJJv0zUwDDvw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -7923,9 +8036,9 @@
}
},
"node_modules/shelljs/node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -8450,7 +8563,6 @@
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -8480,7 +8592,6 @@
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -8774,9 +8885,9 @@
}
},
"node_modules/tmp": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
- "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -8827,6 +8938,7 @@
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"is-number": "^7.0.0"
},
@@ -9023,9 +9135,9 @@
}
},
"node_modules/typechain/node_modules/brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -9140,9 +9252,9 @@
}
},
"node_modules/typed-query-selector": {
- "version": "2.12.1",
- "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
- "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true,
"license": "MIT"
},
@@ -9261,9 +9373,9 @@
"peer": true
},
"node_modules/viem": {
- "version": "2.47.18",
- "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.18.tgz",
- "integrity": "sha512-m3kr+/i8MddeY5fmB2y2v5B0vDL0x8R4v/8gai4Lh4jh8KOWlQqml7PFLtilNomoDm3mINxdA0JnYBJfknNoEg==",
+ "version": "2.55.15",
+ "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.15.tgz",
+ "integrity": "sha512-ka9SfSJ3ZfhuUEzTGmufwALRvEPVKM068tF0AwfdRZagqA3yuFa/QoXIvALzr9y47m4Wiisl3yEoYWuFQso6Ng==",
"dev": true,
"funding": [
{
@@ -9280,8 +9392,8 @@
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
- "ox": "0.14.15",
- "ws": "8.18.3"
+ "ox": "0.14.33",
+ "ws": "8.21.0"
},
"peerDependencies": {
"typescript": ">=5.0.4"
@@ -9324,9 +9436,9 @@
}
},
"node_modules/viem/node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -9624,7 +9736,8 @@
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
"integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
"dev": true,
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "peer": true
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
@@ -9651,7 +9764,6 @@
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@@ -9671,9 +9783,9 @@
"dev": true
},
"node_modules/ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9708,6 +9820,7 @@
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
@@ -9727,6 +9840,7 @@
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
"license": "ISC",
+ "peer": true,
"engines": {
"node": ">=10"
}
@@ -9779,6 +9893,7 @@
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dev": true,
"license": "ISC",
+ "peer": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
@@ -9833,9 +9948,9 @@
},
"dependencies": {
"@adraffy/ens-normalize": {
- "version": "1.10.1",
- "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
- "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
+ "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
"dev": true
},
"@aduh95/viz.js": {
@@ -10186,7 +10301,6 @@
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
- "peer": true,
"requires": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@@ -10200,29 +10314,25 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
- "peer": true,
"requires": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@@ -10234,7 +10344,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
- "peer": true,
"requires": {
"ansi-regex": "^6.2.2"
}
@@ -10244,7 +10353,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
- "peer": true,
"requires": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@@ -10604,13 +10712,12 @@
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
- "optional": true,
- "peer": true
+ "optional": true
},
"@puppeteer/browsers": {
- "version": "2.13.0",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",
- "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",
+ "version": "2.13.2",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz",
+ "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==",
"dev": true,
"requires": {
"debug": "^4.4.3",
@@ -10623,15 +10730,15 @@
},
"dependencies": {
"semver": {
- "version": "7.7.4",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
- "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true
},
"yargs": {
- "version": "17.7.2",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
- "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
"dev": true,
"requires": {
"cliui": "^8.0.1",
@@ -11160,6 +11267,7 @@
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
+ "peer": true,
"requires": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
@@ -11246,13 +11354,14 @@
}
},
"axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
+ "version": "1.19.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz",
+ "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==",
"dev": true,
"requires": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.6",
+ "https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
@@ -11344,16 +11453,17 @@
}
},
"basic-ftp": {
- "version": "5.2.2",
- "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.2.tgz",
- "integrity": "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
+ "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"dev": true
},
"binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"blakejs": {
"version": "1.2.1",
@@ -11405,9 +11515,9 @@
}
},
"brace-expansion": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
- "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
@@ -11418,6 +11528,7 @@
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
+ "peer": true,
"requires": {
"fill-range": "^7.1.1"
}
@@ -11632,9 +11743,9 @@
},
"dependencies": {
"undici": {
- "version": "7.25.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
- "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true
}
}
@@ -11987,7 +12098,6 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
- "peer": true,
"requires": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -11998,15 +12108,13 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
- "dev": true,
- "peer": true
+ "dev": true
},
"shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
- "peer": true,
"requires": {
"shebang-regex": "^3.0.0"
}
@@ -12015,15 +12123,13 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
- "dev": true,
- "peer": true
+ "dev": true
},
"which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
- "peer": true,
"requires": {
"isexe": "^2.0.0"
}
@@ -12181,16 +12287,17 @@
"dev": true
},
"devtools-protocol": {
- "version": "0.0.1595872",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1595872.tgz",
- "integrity": "sha512-kRfgp8vWVjBu/fbYCiVFiOqsCk3CrMKEo3WbgGT2NXK2dG7vawWPBljixajVgGK9II8rDO9G0oD0zLt3I1daRg==",
+ "version": "0.0.1608973",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",
+ "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"dev": true
},
"diff": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz",
"integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"diff-match-patch": {
"version": "1.0.5",
@@ -12270,8 +12377,7 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"elliptic": {
"version": "6.6.1",
@@ -12557,18 +12663,18 @@
}
},
"ethers": {
- "version": "6.16.0",
- "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
- "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
+ "version": "6.17.0",
+ "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz",
+ "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==",
"dev": true,
"requires": {
- "@adraffy/ens-normalize": "1.10.1",
+ "@adraffy/ens-normalize": "1.11.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.2",
"@types/node": "22.7.5",
"aes-js": "4.0.0-beta.5",
"tslib": "2.7.0",
- "ws": "8.17.1"
+ "ws": "8.21.0"
},
"dependencies": {
"@types/node": {
@@ -12587,9 +12693,9 @@
"dev": true
},
"ws": {
- "version": "8.17.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
- "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"requires": {}
}
@@ -12708,9 +12814,9 @@
"peer": true
},
"fast-uri": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
- "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"peer": true
},
@@ -12744,6 +12850,7 @@
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
+ "peer": true,
"requires": {
"to-regex-range": "^5.0.1"
}
@@ -12785,7 +12892,6 @@
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
- "peer": true,
"requires": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
@@ -12795,22 +12901,21 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "peer": true
+ "dev": true
}
}
},
"form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
+ "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
+ "hasown": "^2.0.4",
+ "mime-types": "^2.1.35"
}
},
"fp-ts": {
@@ -12835,14 +12940,16 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
- "optional": true
+ "optional": true,
+ "peer": true
},
"function-bind": {
"version": "1.1.2",
@@ -12987,11 +13094,26 @@
}
}
},
+ "glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "dev": true,
+ "requires": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ }
+ },
"glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
+ "peer": true,
"requires": {
"is-glob": "^4.0.1"
}
@@ -13036,9 +13158,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"peer": true,
"requires": {
@@ -13118,9 +13240,9 @@
}
},
"hardhat": {
- "version": "2.28.6",
- "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.28.6.tgz",
- "integrity": "sha512-zQze7qe+8ltwHvhX5NQ8sN1N37WWZGw8L63y+2XcPxGwAjc/SMF829z3NS6o1krX0sryhAsVBK/xrwUqlsot4Q==",
+ "version": "2.29.0",
+ "resolved": "https://registry.npmjs.org/hardhat/-/hardhat-2.29.0.tgz",
+ "integrity": "sha512-tsj5mCSjDCFOhGfBl4vwqDEcwdlES9VUzRWfdrwvEVhus6D8W6u+WfUKRLLwFhKGS/8lKPoXGsjYWPXl3CCpOg==",
"dev": true,
"requires": {
"@ethereumjs/util": "^9.1.0",
@@ -13148,7 +13270,7 @@
"lodash": "^4.17.11",
"micro-eth-signer": "^0.14.0",
"mnemonist": "^0.38.0",
- "mocha": "^10.0.0",
+ "mocha": "^11.1.0",
"p-map": "^4.0.0",
"picocolors": "^1.1.0",
"raw-body": "^2.4.1",
@@ -13197,6 +13319,12 @@
"@scure/base": "~1.1.0"
}
},
+ "diff": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
+ "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+ "dev": true
+ },
"ethereum-cryptography": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-1.2.0.tgz",
@@ -13248,6 +13376,35 @@
"p-locate": "^5.0.0"
}
},
+ "mocha": {
+ "version": "11.8.0",
+ "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz",
+ "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==",
+ "dev": true,
+ "requires": {
+ "browser-stdout": "^1.3.1",
+ "chokidar": "^4.0.1",
+ "debug": "^4.3.5",
+ "diff": "^7.0.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-up": "^5.0.0",
+ "glob": "^10.4.5",
+ "he": "^1.2.0",
+ "is-path-inside": "^3.0.3",
+ "js-yaml": "^4.1.0",
+ "log-symbols": "^4.1.0",
+ "minimatch": "^9.0.5",
+ "ms": "^2.1.3",
+ "picocolors": "^1.1.1",
+ "serialize-javascript": "^6.0.2",
+ "strip-json-comments": "^3.1.1",
+ "supports-color": "^8.1.1",
+ "workerpool": "^9.2.0",
+ "yargs": "^17.7.2",
+ "yargs-parser": "^21.1.1",
+ "yargs-unparser": "^2.0.0"
+ }
+ },
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -13266,11 +13423,47 @@
"p-limit": "^3.0.2"
}
},
+ "supports-color": {
+ "version": "8.1.1",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ },
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true
+ },
+ "workerpool": {
+ "version": "9.3.4",
+ "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz",
+ "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==",
+ "dev": true
+ },
+ "yargs": {
+ "version": "17.7.3",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz",
+ "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==",
+ "dev": true,
+ "requires": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ }
+ },
+ "yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "dev": true
}
}
},
@@ -13357,31 +13550,6 @@
"@scure/bip32": "1.4.0",
"@scure/bip39": "1.3.0"
}
- },
- "glob": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
- "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
- "dev": true,
- "peer": true,
- "requires": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- }
- },
- "minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "dev": true,
- "peer": true,
- "requires": {
- "brace-expansion": "^2.0.2"
- }
}
}
},
@@ -13499,9 +13667,9 @@
}
},
"hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"requires": {
"function-bind": "^1.1.2"
@@ -13616,9 +13784,9 @@
"peer": true
},
"immutable": {
- "version": "4.3.8",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz",
- "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==",
+ "version": "4.3.9",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz",
+ "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==",
"dev": true
},
"import-fresh": {
@@ -13642,6 +13810,7 @@
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"dev": true,
+ "peer": true,
"requires": {
"once": "^1.3.0",
"wrappy": "1"
@@ -13677,9 +13846,9 @@
}
},
"ip-address": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
- "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
+ "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"dev": true
},
"is-arrayish": {
@@ -13693,6 +13862,7 @@
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
+ "peer": true,
"requires": {
"binary-extensions": "^2.0.0"
}
@@ -13708,7 +13878,8 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"is-fullwidth-code-point": {
"version": "3.0.0",
@@ -13721,6 +13892,7 @@
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
+ "peer": true,
"requires": {
"is-extglob": "^2.1.1"
}
@@ -13736,6 +13908,13 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "peer": true
+ },
+ "is-path-inside": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
"dev": true
},
"is-plain-obj": {
@@ -13783,8 +13962,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"isows": {
"version": "1.0.7",
@@ -13799,7 +13977,6 @@
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
- "peer": true,
"requires": {
"@isaacs/cliui": "^8.0.2",
"@pkgjs/parseargs": "^0.11.0"
@@ -13824,9 +14001,9 @@
"dev": true
},
"js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
@@ -13995,8 +14172,7 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true,
- "peer": true
+ "dev": true
},
"lru-queue": {
"version": "0.1.0",
@@ -14153,6 +14329,15 @@
"integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==",
"dev": true
},
+ "minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^2.0.2"
+ }
+ },
"minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -14164,8 +14349,7 @@
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
- "dev": true,
- "peer": true
+ "dev": true
},
"mitt": {
"version": "3.0.1",
@@ -14197,6 +14381,7 @@
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz",
"integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==",
"dev": true,
+ "peer": true,
"requires": {
"ansi-colors": "^4.1.3",
"browser-stdout": "^1.3.1",
@@ -14225,6 +14410,7 @@
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
+ "peer": true,
"requires": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
@@ -14241,6 +14427,7 @@
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dev": true,
+ "peer": true,
"requires": {
"locate-path": "^6.0.0",
"path-exists": "^4.0.0"
@@ -14251,6 +14438,7 @@
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dev": true,
+ "peer": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -14264,6 +14452,7 @@
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
"integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
"dev": true,
+ "peer": true,
"requires": {
"p-locate": "^5.0.0"
}
@@ -14273,6 +14462,7 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
+ "peer": true,
"requires": {
"brace-expansion": "^2.0.1"
}
@@ -14282,6 +14472,7 @@
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
+ "peer": true,
"requires": {
"yocto-queue": "^0.1.0"
}
@@ -14291,6 +14482,7 @@
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
+ "peer": true,
"requires": {
"p-limit": "^3.0.2"
}
@@ -14300,6 +14492,7 @@
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
+ "peer": true,
"requires": {
"picomatch": "^2.2.1"
}
@@ -14309,6 +14502,7 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
+ "peer": true,
"requires": {
"has-flag": "^4.0.0"
}
@@ -14397,7 +14591,8 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"nth-check": {
"version": "2.1.1",
@@ -14472,9 +14667,9 @@
"dev": true
},
"ox": {
- "version": "0.14.15",
- "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.15.tgz",
- "integrity": "sha512-3TubCmbKen/cuZQzX0qDbOS5lojjdSZ90lqKxWIDWd5siuJ0IJBaTXMYs8eMPLcraqnOwGZazz3apHPGiRCkGQ==",
+ "version": "0.14.33",
+ "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz",
+ "integrity": "sha512-rooA/4o7bBof4Ge2VH/eovfNPb/AEEYyrNj03wggc55g5HZD8Pjs/OeWhttgjic3dDcqn0r29bDuvQEdTiUemQ==",
"dev": true,
"peer": true,
"requires": {
@@ -14488,13 +14683,6 @@
"eventemitter3": "5.0.1"
},
"dependencies": {
- "@adraffy/ens-normalize": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz",
- "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==",
- "dev": true,
- "peer": true
- },
"@noble/curves": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz",
@@ -14571,8 +14759,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"parent-module": {
"version": "1.0.1",
@@ -14655,7 +14842,6 @@
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
- "peer": true,
"requires": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
@@ -14715,7 +14901,8 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"pify": {
"version": "4.0.1",
@@ -14832,38 +15019,38 @@
}
},
"puppeteer": {
- "version": "24.41.0",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.41.0.tgz",
- "integrity": "sha512-W6Fk0J3TPjjtwjXOyR/qf+YaL0H/Uq8HIgHcXG4mNM/IgbKMCH/HPyK0Fi2qbTU/QpSl9bCte2yBpGHKejTpIw==",
+ "version": "24.43.1",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.43.1.tgz",
+ "integrity": "sha512-/FSOViCrqRdb1HDocpsM9Z1giA71gTQPUt3SpHGVRALKAy/rJr1fLFYZW9F23qPxqVxTHQnbh/5B5opJST3kAw==",
"dev": true,
"requires": {
- "@puppeteer/browsers": "2.13.0",
+ "@puppeteer/browsers": "2.13.2",
"chromium-bidi": "14.0.0",
"cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1595872",
- "puppeteer-core": "24.41.0",
- "typed-query-selector": "^2.12.1"
+ "devtools-protocol": "0.0.1608973",
+ "puppeteer-core": "24.43.1",
+ "typed-query-selector": "^2.12.2"
}
},
"puppeteer-core": {
- "version": "24.41.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.41.0.tgz",
- "integrity": "sha512-rLIUri7E/NQ3APSEYCCozaSJx0u8Tu9wxO6BJwnvXmIgILSK3L0TombaVh3izp1njAGrO6H2ru0hcIrLF+gWLw==",
+ "version": "24.43.1",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz",
+ "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==",
"dev": true,
"requires": {
- "@puppeteer/browsers": "2.13.0",
+ "@puppeteer/browsers": "2.13.2",
"chromium-bidi": "14.0.0",
"debug": "^4.4.3",
- "devtools-protocol": "0.0.1595872",
- "typed-query-selector": "^2.12.1",
+ "devtools-protocol": "0.0.1608973",
+ "typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.1",
- "ws": "^8.19.0"
+ "ws": "^8.20.0"
},
"dependencies": {
"ws": {
- "version": "8.20.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
- "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"requires": {}
}
@@ -14935,9 +15122,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"peer": true,
"requires": {
@@ -15083,9 +15270,9 @@
}
},
"brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"peer": true,
"requires": {
@@ -15115,9 +15302,9 @@
"peer": true
},
"js-yaml": {
- "version": "3.14.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
- "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz",
+ "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==",
"dev": true,
"peer": true,
"requires": {
@@ -15171,9 +15358,9 @@
"peer": true
},
"secp256k1": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.4.tgz",
- "integrity": "sha512-6JfvwvjUOn8F/jUoBY2Q1v5WY5XS+rj8qSe0v8Y4ezH4InLgTEeOOPQsRll9OV429Pvo6BCHGavIyJfr3TAhsw==",
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-4.0.5.tgz",
+ "integrity": "sha512-SQZi5+/uiJIFPYbeRrVuu77Sr3bFOTq0oCQs67CqYwdmg0lhnqi/8djSWhzNO3GKGOqxBYCdx8zJJv0zUwDDvw==",
"dev": true,
"peer": true,
"requires": {
@@ -15288,9 +15475,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"peer": true,
"requires": {
@@ -15688,7 +15875,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
- "peer": true,
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -15709,7 +15895,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
- "peer": true,
"requires": {
"ansi-regex": "^5.0.1"
}
@@ -15923,9 +16108,9 @@
}
},
"tmp": {
- "version": "0.2.5",
- "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
- "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+ "version": "0.2.7",
+ "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
+ "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
"dev": true
},
"to-buffer": {
@@ -15954,6 +16139,7 @@
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
+ "peer": true,
"requires": {
"is-number": "^7.0.0"
}
@@ -16083,9 +16269,9 @@
},
"dependencies": {
"brace-expansion": {
- "version": "1.1.14",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
- "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"peer": true,
"requires": {
@@ -16169,9 +16355,9 @@
}
},
"typed-query-selector": {
- "version": "2.12.1",
- "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",
- "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",
+ "version": "2.12.2",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"dev": true
},
"typescript": {
@@ -16251,9 +16437,9 @@
"peer": true
},
"viem": {
- "version": "2.47.18",
- "resolved": "https://registry.npmjs.org/viem/-/viem-2.47.18.tgz",
- "integrity": "sha512-m3kr+/i8MddeY5fmB2y2v5B0vDL0x8R4v/8gai4Lh4jh8KOWlQqml7PFLtilNomoDm3mINxdA0JnYBJfknNoEg==",
+ "version": "2.55.15",
+ "resolved": "https://registry.npmjs.org/viem/-/viem-2.55.15.tgz",
+ "integrity": "sha512-ka9SfSJ3ZfhuUEzTGmufwALRvEPVKM068tF0AwfdRZagqA3yuFa/QoXIvALzr9y47m4Wiisl3yEoYWuFQso6Ng==",
"dev": true,
"peer": true,
"requires": {
@@ -16263,8 +16449,8 @@
"@scure/bip39": "1.6.0",
"abitype": "1.2.3",
"isows": "1.0.7",
- "ox": "0.14.15",
- "ws": "8.18.3"
+ "ox": "0.14.33",
+ "ws": "8.21.0"
},
"dependencies": {
"@noble/curves": {
@@ -16285,9 +16471,9 @@
"peer": true
},
"ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"dev": true,
"peer": true,
"requires": {}
@@ -16497,7 +16683,8 @@
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
"integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"wrap-ansi": {
"version": "7.0.0",
@@ -16515,7 +16702,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
- "peer": true,
"requires": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@@ -16529,9 +16715,9 @@
"dev": true
},
"ws": {
- "version": "7.5.10",
- "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
- "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
+ "version": "7.5.13",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
+ "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"dev": true,
"requires": {}
},
@@ -16546,6 +16732,7 @@
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dev": true,
+ "peer": true,
"requires": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
@@ -16561,6 +16748,7 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dev": true,
+ "peer": true,
"requires": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
@@ -16573,7 +16761,8 @@
"version": "20.2.9",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
- "dev": true
+ "dev": true,
+ "peer": true
},
"yargs-unparser": {
"version": "2.0.0",
diff --git a/remappings.txt b/remappings.txt
index 5f1ccf52..80d676d5 100644
--- a/remappings.txt
+++ b/remappings.txt
@@ -1,3 +1,4 @@
CMTAT/=lib/CMTAT/contracts/
RuleEngine/=lib/RuleEngine/src/
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
+ERC3643/=lib/ERC-3643/contracts/
diff --git a/script/DeployCMTATWithBlacklist.s.sol b/script/DeployCMTATWithBlacklist.s.sol
index 5d446bc2..e64d865f 100644
--- a/script/DeployCMTATWithBlacklist.s.sol
+++ b/script/DeployCMTATWithBlacklist.s.sol
@@ -1,40 +1,72 @@
// SPDX-License-Identifier: MPL-2.0
pragma solidity ^0.8.20;
-import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
-import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
+import {CMTATDeploymentBase} from "./base/CMTATDeploymentBase.sol";
-contract DeployCMTATWithBlacklist is Script {
- function deploy(address admin, address forwarder) public returns (CMTATStandardStandalone token, RuleBlacklist rule) {
- ICMTATConstructor.ERC20Attributes memory erc20Attributes =
- ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
- ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
- ICMTATConstructor.ExtraInformationAttributes(
- "CMTAT_ISIN",
- IERC1643CMTAT.DocumentInfo(
- "Terms", "https://cmta.ch", 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b
- ),
- "CMTAT_info"
- );
- ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
-
- token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
- rule = new RuleBlacklist(admin, address(0));
+/**
+ * @title DeployCMTATWithBlacklist
+ * @notice Deploys a CMTAT token guarded by a single `RuleBlacklist`.
+ *
+ * Deployment order:
+ * 1. `CMTATStandardStandalone` — the token, with `deployer` as temporary admin
+ * 2. `RuleBlacklist` — blocks blacklisted sender / recipient / spender
+ * 3. `token.setRuleEngine(rule)`
+ * 4. Hand `DEFAULT_ADMIN_ROLE` to `admin`, renounce the deployer's
+ *
+ * @dev **Topology B (direct binding).** The rule is bound straight to the token, with no RuleEngine
+ * in between, so inside the rule `msg.sender` is the token itself. That is fine for a
+ * validation rule such as this one. It is *not* interchangeable with the RuleEngine topology
+ * for operation rules: see `CLAUDE.md`, "The two integration topologies". If you extend this
+ * script with a second rule you need a RuleEngine; start from
+ * `DeployCMTATWithBlacklistAndSanctionsList` instead.
+ */
+contract DeployCMTATWithBlacklist is CMTATDeploymentBase {
+ /**
+ * @notice Deploys and wires the token and its rule.
+ * @dev `deployer` is explicit rather than read as `address(this)`: under `forge script` the
+ * broadcaster makes the calls below, not the script contract, and Foundry rejects
+ * `address(this)` inside a broadcast ("script contracts are ephemeral and their addresses
+ * should not be relied upon"). Reading it there made this script revert before it could
+ * deploy anything (CLAUDE_ANALYSIS_SCRIPT.md S-1).
+ * @param admin Address that ends up holding `DEFAULT_ADMIN_ROLE` on the token.
+ * @param deployer Address that executes the calls and holds the temporary admin role:
+ * `msg.sender` under `forge script`, the script contract's address under test.
+ * @param forwarder ERC-2771 trusted forwarder; `address(0)` disables meta-transactions.
+ * @return token The deployed CMTAT.
+ * @return rule The blacklist rule bound to it.
+ */
+ function deploy(address admin, address deployer, address forwarder)
+ public
+ virtual
+ returns (CMTATStandardStandalone token, RuleBlacklist rule)
+ {
+ token = new CMTATStandardStandalone(
+ forwarder, deployer, _erc20Attributes(), _extraInformationAttributes(), _emptyEngine()
+ );
+ rule = new RuleBlacklist(admin, forwarder);
token.setRuleEngine(IRuleEngine(address(rule)));
- if (admin != address(this)) {
- token.grantRole(bytes32(0), admin);
- token.renounceRole(bytes32(0), address(this));
+ if (admin != deployer) {
+ token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+ token.renounceRole(token.DEFAULT_ADMIN_ROLE(), deployer);
}
+
+ _logDeployment("CMTAT token ", address(token));
+ _logDeployment("RuleBlacklist", address(rule));
}
- function run() external returns (CMTATStandardStandalone token, RuleBlacklist rule) {
+ /**
+ * @notice Broadcast entrypoint.
+ * @dev The broadcaster is both the final admin and the acting deployer, so the hand-over is a
+ * no-op and no temporary rights outlive the transaction.
+ */
+ function run() external virtual returns (CMTATStandardStandalone token, RuleBlacklist rule) {
vm.startBroadcast();
- (token, rule) = deploy(msg.sender, address(0));
+ (token, rule) = deploy(msg.sender, msg.sender, _forwarder());
vm.stopBroadcast();
}
}
diff --git a/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol b/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
index 3515894d..8d0af272 100644
--- a/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
+++ b/script/DeployCMTATWithBlacklistAndSanctionsList.s.sol
@@ -1,31 +1,55 @@
// SPDX-License-Identifier: MPL-2.0
pragma solidity ^0.8.20;
-import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
-import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {RuleSanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
+import {CMTATDeploymentBase} from "./base/CMTATDeploymentBase.sol";
/**
* @title DeployCMTATWithBlacklistAndSanctionsList
- * @notice Deploys a CMTAT token with a RuleEngine enforcing two validation rules:
- * a blacklist (RuleBlacklist) and a sanctions screening (RuleSanctionsList).
+ * @notice Deploys a CMTAT token behind a RuleEngine enforcing two validation rules: a blacklist and
+ * sanctions screening.
*
* Deployment order:
- * 1. CMTATStandardStandalone — token contract (deployer as temporary admin)
- * 2. RuleBlacklist — blocks blacklisted senders / recipients
- * 3. RuleSanctionsList — blocks sanctioned addresses via Chainalysis oracle
- * 4. RuleEngine — aggregates both rules; token bound at construction
- * 5. Wire RuleEngine → CMTAT via setRuleEngine
- * 6. Hand over all admin roles to `admin`
+ * 1. `CMTATStandardStandalone` — the token, with `deployer` as temporary admin
+ * 2. `RuleBlacklist` — blocks blacklisted sender / recipient / spender
+ * 3. `RuleSanctionsList` — blocks sanctioned addresses via a Chainalysis-style oracle
+ * 4. `RuleEngine` — aggregates both; the token is bound at construction
+ * 5. `token.setRuleEngine(...)`
+ * 6. Hand every admin role to `admin`, renounce the deployer's
+ *
+ * @dev **Topology A (RuleEngine).** Rules are reached through the engine, so inside a rule
+ * `msg.sender` is the RuleEngine rather than the token. See `CLAUDE.md`, "The two integration
+ * topologies", before adding an operation rule here.
+ *
+ * @dev Rule order affects only *which* restriction code a rejected transfer reports (the engine
+ * returns the first non-zero code), not whether it is rejected.
*/
-contract DeployCMTATWithBlacklistAndSanctionsList is Script {
- function deploy(address admin, address forwarder, ISanctionsList sanctionsOracle)
+contract DeployCMTATWithBlacklistAndSanctionsList is CMTATDeploymentBase {
+ /**
+ * @notice Deploys and wires the whole set.
+ * @dev `deployer` is explicit rather than read as `address(this)`: under `forge script` the
+ * broadcaster makes these calls, and Foundry rejects `address(this)` inside a broadcast, so
+ * the previous version reverted before deploying anything (CLAUDE_ANALYSIS_SCRIPT.md S-1).
+ * @param admin Address that ends up holding every admin role.
+ * @param deployer Address that executes the calls and holds the temporary admin roles:
+ * `msg.sender` under `forge script`, the script contract's address under test.
+ * @param forwarder ERC-2771 trusted forwarder; `address(0)` disables meta-transactions.
+ * @param sanctionsOracle Sanctions oracle. `address(0)` leaves screening disabled until
+ * `RuleSanctionsList.setSanctionListOracle` is called. **An unset oracle fails OPEN** —
+ * the rule is registered, the engine reports no error, and every transfer passes it.
+ * @return token The deployed CMTAT.
+ * @return ruleEngine The engine holding both rules.
+ * @return ruleBlacklist The blacklist rule.
+ * @return ruleSanctionsList The sanctions rule.
+ */
+ function deploy(address admin, address deployer, address forwarder, ISanctionsList sanctionsOracle)
public
+ virtual
returns (
CMTATStandardStandalone token,
RuleEngine ruleEngine,
@@ -33,47 +57,47 @@ contract DeployCMTATWithBlacklistAndSanctionsList is Script {
RuleSanctionsList ruleSanctionsList
)
{
- ICMTATConstructor.ERC20Attributes memory erc20Attributes =
- ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
- ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
- ICMTATConstructor.ExtraInformationAttributes(
- "CMTAT_ISIN",
- IERC1643CMTAT.DocumentInfo(
- "Terms", "https://cmta.ch", 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b
- ),
- "CMTAT_info"
- );
- ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
-
- // Deploy CMTAT with the deployer as temporary admin so we can configure it.
- token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
+ // 1. The token, with the deployer as temporary admin so the wiring below is permitted.
+ token = new CMTATStandardStandalone(
+ forwarder, deployer, _erc20Attributes(), _extraInformationAttributes(), _emptyEngine()
+ );
- // Deploy rules; each rule is owned directly by the intended admin.
- ruleBlacklist = new RuleBlacklist(admin, address(0));
- ruleSanctionsList = new RuleSanctionsList(admin, address(0), sanctionsOracle);
+ // 2-3. Address-screening rules; each is owned by the intended admin from the start.
+ ruleBlacklist = new RuleBlacklist(admin, forwarder);
+ ruleSanctionsList = new RuleSanctionsList(admin, forwarder, sanctionsOracle);
- // Deploy RuleEngine with the deployer as temporary admin so we can add rules.
- // The token is bound at construction so it is authorised to call transferred().
- ruleEngine = new RuleEngine(address(this), forwarder, address(token));
-
- // Register both rules in evaluation order: blacklist first, sanctions second.
+ // 4. The engine, deployer-owned for now so rules can be added. Binding the token at
+ // construction is what authorises it to call `transferred()` on the engine.
+ ruleEngine = new RuleEngine(deployer, forwarder, address(token));
ruleEngine.addRule(ruleBlacklist);
ruleEngine.addRule(ruleSanctionsList);
- // Connect the RuleEngine to the token.
+ // 5. Connect the engine to the token.
token.setRuleEngine(IRuleEngine(address(ruleEngine)));
- // Transfer admin rights to the intended admin and remove the deployer.
- if (admin != address(this)) {
- ruleEngine.grantRole(bytes32(0), admin);
- ruleEngine.renounceRole(bytes32(0), address(this));
- token.grantRole(bytes32(0), admin);
- token.renounceRole(bytes32(0), address(this));
+ // 6. Hand over, and drop the deployer's rights so the deployment key is not a standing risk.
+ if (admin != deployer) {
+ ruleEngine.grantRole(ruleEngine.DEFAULT_ADMIN_ROLE(), admin);
+ ruleEngine.renounceRole(ruleEngine.DEFAULT_ADMIN_ROLE(), deployer);
+ token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+ token.renounceRole(token.DEFAULT_ADMIN_ROLE(), deployer);
}
+
+ _logDeployment("CMTAT token ", address(token));
+ _logDeployment("RuleEngine ", address(ruleEngine));
+ _logDeployment("RuleBlacklist ", address(ruleBlacklist));
+ _logDeployment("RuleSanctionsList", address(ruleSanctionsList));
}
+ /**
+ * @notice Broadcast entrypoint.
+ * @dev The oracle is left unset because its address is chain-specific (Chainalysis publishes one
+ * per network). **Until it is set, sanctions screening passes everything** — call
+ * `setSanctionListOracle` before the token goes live, or set `SANCTIONS_ORACLE`.
+ */
function run()
external
+ virtual
returns (
CMTATStandardStandalone token,
RuleEngine ruleEngine,
@@ -82,10 +106,8 @@ contract DeployCMTATWithBlacklistAndSanctionsList is Script {
)
{
vm.startBroadcast();
- // Pass address(0) for sanctionsOracle to deploy without an oracle configured.
- // The oracle can be set post-deployment via RuleSanctionsList.setSanctionListOracle().
(token, ruleEngine, ruleBlacklist, ruleSanctionsList) =
- deploy(msg.sender, address(0), ISanctionsList(address(0)));
+ deploy(msg.sender, msg.sender, _forwarder(), ISanctionsList(vm.envOr("SANCTIONS_ORACLE", address(0))));
vm.stopBroadcast();
}
}
diff --git a/script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol b/script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol
new file mode 100644
index 00000000..478c012a
--- /dev/null
+++ b/script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol
@@ -0,0 +1,148 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
+import {RuleMaxTotalSupply} from "src/rules/validation/deployment/RuleMaxTotalSupply.sol";
+import {RuleSanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
+import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
+import {CMTATDeploymentBase} from "./base/CMTATDeploymentBase.sol";
+
+/**
+ * @title DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply
+ * @notice Deploys a CMTAT token behind a RuleEngine enforcing three validation rules: a blacklist,
+ * sanctions screening, and a hard cap on total supply.
+ *
+ * Deployment order:
+ * 1. `CMTATStandardStandalone` — the token (deployer as temporary admin, so it can be wired)
+ * 2. `RuleBlacklist` — blocks blacklisted sender / recipient / spender
+ * 3. `RuleSanctionsList` — blocks sanctioned addresses via a Chainalysis-style oracle
+ * 4. `RuleMaxTotalSupply` — rejects mints that would push `totalSupply` past the cap
+ * 5. `RuleEngine` — aggregates all three; the token is bound at construction
+ * 6. `token.setRuleEngine(...)`
+ * 7. Hand every admin role to `admin`, renounce the deployer's
+ *
+ * @dev **Step 4 must follow step 1.** Unlike the other two rules, `RuleMaxTotalSupply` validates its
+ * token at construction — non-zero, has code, and `totalSupply()` callable — so it cannot be
+ * deployed before the token exists. Passing a placeholder address reverts with
+ * `RuleMaxTotalSupply_TokenIsNotAContract`.
+ *
+ * @dev **One `RuleMaxTotalSupply` instance protects one token.** It reads `totalSupply()` from the
+ * `tokenContract` it was given, never from whichever token triggered the check, and behind a
+ * RuleEngine it cannot learn that identity. Do not add this instance to a second RuleEngine:
+ * both tokens would be capped against this one's supply. Deploy a second instance instead.
+ *
+ * @dev Rule order matters only for *which* restriction code a rejected transfer reports — the
+ * RuleEngine returns the first non-zero code — not for whether it is rejected. Blacklist first,
+ * then sanctions, then the supply cap, so an address-level rejection is reported in preference
+ * to a supply-level one.
+ */
+contract DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply is CMTATDeploymentBase {
+ /**
+ * @notice Deploys and wires the whole set.
+ * @dev `deployer` is passed explicitly rather than read as `address(this)`, because the two
+ * execution contexts disagree about who makes the wiring calls: under `forge script` the
+ * broadcaster does, while a test calling {deploy} directly makes them from the script
+ * contract. Foundry also *rejects* `address(this)` inside a broadcast outright — "script
+ * contracts are ephemeral and their addresses should not be relied upon" — so a script that
+ * reads it works under test and reverts on the real deployment path.
+ * @param admin Address that ends up holding every admin role.
+ * @param deployer Address that executes the calls below and therefore holds the temporary admin
+ * roles: `msg.sender` under `forge script`, the script contract's address under test.
+ * @param forwarder ERC-2771 trusted forwarder; `address(0)` disables meta-transactions.
+ * @param sanctionsOracle Sanctions oracle; `address(0)` leaves screening disabled until
+ * `RuleSanctionsList.setSanctionListOracle` is called. **An unset oracle fails OPEN** —
+ * the rule passes every transfer.
+ * @param maxTotalSupply The supply ceiling enforced on mints.
+ * @return token The deployed CMTAT.
+ * @return ruleEngine The engine holding the three rules.
+ * @return ruleBlacklist The blacklist rule.
+ * @return ruleSanctionsList The sanctions rule.
+ * @return ruleMaxTotalSupply The supply-cap rule.
+ */
+ function deploy(
+ address admin,
+ address deployer,
+ address forwarder,
+ ISanctionsList sanctionsOracle,
+ uint256 maxTotalSupply
+ )
+ public
+ returns (
+ CMTATStandardStandalone token,
+ RuleEngine ruleEngine,
+ RuleBlacklist ruleBlacklist,
+ RuleSanctionsList ruleSanctionsList,
+ RuleMaxTotalSupply ruleMaxTotalSupply
+ )
+ {
+ // 1. The token, with the deployer as temporary admin so the wiring below is permitted.
+ token = new CMTATStandardStandalone(
+ forwarder, deployer, _erc20Attributes(), _extraInformationAttributes(), _emptyEngine()
+ );
+
+ // 2-3. Address-screening rules; each is owned by the intended admin from the start.
+ ruleBlacklist = new RuleBlacklist(admin, forwarder);
+ ruleSanctionsList = new RuleSanctionsList(admin, forwarder, sanctionsOracle);
+
+ // 4. The supply cap. MUST come after the token: the constructor probes `totalSupply()`.
+ ruleMaxTotalSupply = new RuleMaxTotalSupply(admin, address(token), maxTotalSupply);
+
+ // 5. The engine, deployer-owned for now so rules can be added. Binding the token at
+ // construction is what authorises it to call `transferred()` on the engine.
+ ruleEngine = new RuleEngine(deployer, forwarder, address(token));
+
+ ruleEngine.addRule(ruleBlacklist);
+ ruleEngine.addRule(ruleSanctionsList);
+ ruleEngine.addRule(ruleMaxTotalSupply);
+
+ // 6. Connect the engine to the token.
+ token.setRuleEngine(IRuleEngine(address(ruleEngine)));
+
+ // 7. Hand over, and drop the deployer's rights so the deployment key is not a standing risk.
+ if (admin != deployer) {
+ ruleEngine.grantRole(ruleEngine.DEFAULT_ADMIN_ROLE(), admin);
+ ruleEngine.renounceRole(ruleEngine.DEFAULT_ADMIN_ROLE(), deployer);
+ token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+ token.renounceRole(token.DEFAULT_ADMIN_ROLE(), deployer);
+ }
+
+ _logDeployment("CMTAT token ", address(token));
+ _logDeployment("RuleEngine ", address(ruleEngine));
+ _logDeployment("RuleBlacklist ", address(ruleBlacklist));
+ _logDeployment("RuleSanctionsList ", address(ruleSanctionsList));
+ _logDeployment("RuleMaxTotalSupply", address(ruleMaxTotalSupply));
+ }
+
+ /**
+ * @notice Broadcast entrypoint. Deploys with no sanctions oracle and a 1 000 000 unit cap.
+ * @dev The oracle is left unset because its address is chain-specific (Chainalysis publishes one
+ * per network). **Until it is set, sanctions screening is disabled and passes everything** —
+ * call `setSanctionListOracle` before the token goes live.
+ */
+ function run()
+ external
+ virtual
+ returns (
+ CMTATStandardStandalone token,
+ RuleEngine ruleEngine,
+ RuleBlacklist ruleBlacklist,
+ RuleSanctionsList ruleSanctionsList,
+ RuleMaxTotalSupply ruleMaxTotalSupply
+ )
+ {
+ vm.startBroadcast();
+ // The broadcaster is both the final admin and the acting deployer, so the hand-over below
+ // is a no-op and no temporary rights outlive the transaction.
+ (token, ruleEngine, ruleBlacklist, ruleSanctionsList, ruleMaxTotalSupply) = deploy(
+ msg.sender,
+ msg.sender,
+ _forwarder(),
+ ISanctionsList(vm.envOr("SANCTIONS_ORACLE", address(0))),
+ vm.envOr("CMTAT_MAX_SUPPLY", uint256(1_000_000))
+ );
+ vm.stopBroadcast();
+ }
+}
diff --git a/script/DeployCMTATWithWhitelist.s.sol b/script/DeployCMTATWithWhitelist.s.sol
index 63fb6cc6..8b2b51e6 100644
--- a/script/DeployCMTATWithWhitelist.s.sol
+++ b/script/DeployCMTATWithWhitelist.s.sol
@@ -1,43 +1,74 @@
// SPDX-License-Identifier: MPL-2.0
pragma solidity ^0.8.20;
-import {Script} from "forge-std/Script.sol";
-import {ICMTATConstructor, CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
-import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+import {CMTATDeploymentBase} from "./base/CMTATDeploymentBase.sol";
-contract DeployCMTATWithWhitelist is Script {
- function deploy(address admin, address forwarder, bool checkSpender)
+/**
+ * @title DeployCMTATWithWhitelist
+ * @notice Deploys a CMTAT token guarded by a single `RuleWhitelist`.
+ *
+ * Deployment order:
+ * 1. `CMTATStandardStandalone` — the token, with `deployer` as temporary admin
+ * 2. `RuleWhitelist` — transfers allowed only between whitelisted addresses
+ * 3. `token.setRuleEngine(rule)`
+ * 4. Hand `DEFAULT_ADMIN_ROLE` to `admin`, renounce the deployer's
+ *
+ * @dev **Topology B (direct binding).** The rule is bound straight to the token, so inside the rule
+ * `msg.sender` is the token. Fine for a validation rule; not interchangeable with the
+ * RuleEngine topology for operation rules. See `CLAUDE.md`, "The two integration topologies".
+ */
+contract DeployCMTATWithWhitelist is CMTATDeploymentBase {
+ /**
+ * @notice Deploys and wires the token and its rule.
+ * @dev `deployer` is explicit rather than read as `address(this)`; see
+ * `DeployCMTATWithBlacklist` and CLAUDE_ANALYSIS_SCRIPT.md S-1 for why.
+ * @param admin Address that ends up holding `DEFAULT_ADMIN_ROLE` on the token.
+ * @param deployer Address that executes the calls and holds the temporary admin role.
+ * @param forwarder ERC-2771 trusted forwarder; `address(0)` disables meta-transactions.
+ * @param checkSpender When true, `transferFrom` also requires the spender to be whitelisted.
+ * @param allowMintBurn Whether the rule permits mint and burn.
+ *
+ * **This decides whether the token can be issued at all.** The whitelist screens the
+ * mint/burn sentinel `address(0)` like any other participant, so with `false` every mint
+ * is rejected with code 24 (`CODE_MINT_NOT_ALLOWED`) even to a whitelisted investor. The
+ * script previously hard-coded `false` and shipped a token nobody could issue
+ * (CLAUDE_ANALYSIS_SCRIPT.md S-3); {run} now passes `true`. Recoverable either way with
+ * `setAllowMint` / `setAllowBurn`, gated on `DEFAULT_ADMIN_ROLE`.
+ * @return token The deployed CMTAT.
+ * @return rule The whitelist rule bound to it.
+ */
+ function deploy(address admin, address deployer, address forwarder, bool checkSpender, bool allowMintBurn)
public
+ virtual
returns (CMTATStandardStandalone token, RuleWhitelist rule)
{
- ICMTATConstructor.ERC20Attributes memory erc20Attributes =
- ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0);
- ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes =
- ICMTATConstructor.ExtraInformationAttributes(
- "CMTAT_ISIN",
- IERC1643CMTAT.DocumentInfo(
- "Terms", "https://cmta.ch", 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b
- ),
- "CMTAT_info"
- );
- ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
-
- token = new CMTATStandardStandalone(forwarder, address(this), erc20Attributes, extraInformationAttributes, engines);
- rule = new RuleWhitelist(admin, address(0), checkSpender, false);
+ token = new CMTATStandardStandalone(
+ forwarder, deployer, _erc20Attributes(), _extraInformationAttributes(), _emptyEngine()
+ );
+ rule = new RuleWhitelist(admin, forwarder, checkSpender, allowMintBurn);
token.setRuleEngine(IRuleEngine(address(rule)));
- if (admin != address(this)) {
- token.grantRole(bytes32(0), admin);
- token.renounceRole(bytes32(0), address(this));
+ if (admin != deployer) {
+ token.grantRole(token.DEFAULT_ADMIN_ROLE(), admin);
+ token.renounceRole(token.DEFAULT_ADMIN_ROLE(), deployer);
}
+
+ _logDeployment("CMTAT token ", address(token));
+ _logDeployment("RuleWhitelist", address(rule));
}
- function run() external returns (CMTATStandardStandalone token, RuleWhitelist rule) {
+ /**
+ * @notice Broadcast entrypoint.
+ * @dev Deploys with spender checks off and mint/burn allowed, so the token can be issued
+ * immediately. Addresses still have to be whitelisted before any transfer succeeds.
+ */
+ function run() external virtual returns (CMTATStandardStandalone token, RuleWhitelist rule) {
vm.startBroadcast();
- (token, rule) = deploy(msg.sender, address(0), false);
+ (token, rule) = deploy(msg.sender, msg.sender, _forwarder(), false, true);
vm.stopBroadcast();
}
}
diff --git a/script/base/CMTATDeploymentBase.sol b/script/base/CMTATDeploymentBase.sol
new file mode 100644
index 00000000..c142e310
--- /dev/null
+++ b/script/base/CMTATDeploymentBase.sol
@@ -0,0 +1,108 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Script} from "forge-std/Script.sol";
+import {console} from "forge-std/console.sol";
+import {ICMTATConstructor} from "CMTAT/interfaces/technical/ICMTATConstructor.sol";
+import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+
+/**
+ * @title CMTATDeploymentBase
+ * @notice Shared token metadata and helpers for the deployment scripts in `script/`.
+ *
+ * @dev Every script used to repeat the same token-attribute block verbatim, so a CMTAT constructor
+ * change meant editing each one and the failure mode of missing one was a script that still
+ * compiled. The block lives here instead (CLAUDE_ANALYSIS_SCRIPT.md S-4).
+ *
+ * @dev Values are read from the environment with the previous hard-coded constants as fallbacks, so
+ * the scripts stay runnable with no configuration while a real deployment no longer requires
+ * editing source (CLAUDE_ANALYSIS_SCRIPT.md S-5):
+ *
+ * | Variable | Default |
+ * | --- | --- |
+ * | `CMTAT_NAME` | `CMTA Token` |
+ * | `CMTAT_SYMBOL` | `CMTAT` |
+ * | `CMTAT_DECIMALS` | `0` (Swiss-law compliant per the CMTAT specification) |
+ * | `CMTAT_TOKEN_ID` | `CMTAT_ISIN` |
+ * | `CMTAT_TERMS_NAME` | `Terms` |
+ * | `CMTAT_TERMS_URI` | `https://cmta.ch` |
+ * | `CMTAT_TERMS_HASH` | the example document hash |
+ * | `CMTAT_INFORMATION` | `CMTAT_info` |
+ * | `CMTAT_FORWARDER` | `address(0)` (meta-transactions disabled) |
+ *
+ * @dev A script needing different metadata overrides {_erc20Attributes} or
+ * {_extraInformationAttributes} rather than copying the block again.
+ */
+abstract contract CMTATDeploymentBase is Script {
+ /**
+ * @dev The example terms hash carried by every script before this base existed. Kept as the
+ * default so behaviour is unchanged when `CMTAT_TERMS_HASH` is not set.
+ */
+ bytes32 internal constant DEFAULT_TERMS_HASH = 0x9ff867f6592aa9d6d039e7aad6bd71f1659720cbc4dd9eae1554f6eab490098b;
+
+ /**
+ * @notice Name, symbol and decimals for the token being deployed.
+ * @return attributes The ERC-20 attribute struct passed to the CMTAT constructor.
+ */
+ function _erc20Attributes() internal view virtual returns (ICMTATConstructor.ERC20Attributes memory attributes) {
+ attributes = ICMTATConstructor.ERC20Attributes({
+ name: vm.envOr("CMTAT_NAME", string("CMTA Token")),
+ symbol: vm.envOr("CMTAT_SYMBOL", string("CMTAT")),
+ decimalsIrrevocable: uint8(vm.envOr("CMTAT_DECIMALS", uint256(0)))
+ });
+ }
+
+ /**
+ * @notice Identifier, terms document and free-form information for the token.
+ * @return attributes The extra-information struct passed to the CMTAT constructor.
+ */
+ function _extraInformationAttributes()
+ internal
+ view
+ virtual
+ returns (ICMTATConstructor.ExtraInformationAttributes memory attributes)
+ {
+ attributes = ICMTATConstructor.ExtraInformationAttributes({
+ tokenId: vm.envOr("CMTAT_TOKEN_ID", string("CMTAT_ISIN")),
+ terms: IERC1643CMTAT.DocumentInfo({
+ name: vm.envOr("CMTAT_TERMS_NAME", string("Terms")),
+ uri: vm.envOr("CMTAT_TERMS_URI", string("https://cmta.ch")),
+ documentHash: vm.envOr("CMTAT_TERMS_HASH", DEFAULT_TERMS_HASH)
+ }),
+ information: vm.envOr("CMTAT_INFORMATION", string("CMTAT_info"))
+ });
+ }
+
+ /**
+ * @notice The engine slot passed at construction.
+ * @dev Always empty. Every script wires the engine (or the rule, in direct-binding mode) after
+ * deployment with `setRuleEngine`, because the engine has to know the token address and the
+ * token has to exist first.
+ * @return engines A struct holding the zero rule engine.
+ */
+ function _emptyEngine() internal pure virtual returns (ICMTATConstructor.Engine memory engines) {
+ engines = ICMTATConstructor.Engine(IRuleEngine(address(0)));
+ }
+
+ /**
+ * @notice ERC-2771 trusted forwarder to install on the token and every rule.
+ * @dev `address(0)` disables meta-transactions.
+ * @return forwarder The configured forwarder.
+ */
+ function _forwarder() internal view virtual returns (address forwarder) {
+ forwarder = vm.envOr("CMTAT_FORWARDER", address(0));
+ }
+
+ /**
+ * @notice Prints a labelled deployed address.
+ * @dev `forge script` prints return values positionally, which is unreadable once a script
+ * returns five contracts. Labelling them makes the run output usable as a deployment record
+ * (CLAUDE_ANALYSIS_SCRIPT.md S-8).
+ * @param label Human-readable contract name.
+ * @param deployed The deployed address.
+ */
+ function _logDeployment(string memory label, address deployed) internal view virtual {
+ console.log(label, deployed);
+ }
+}
diff --git a/src/mocks/AggregatorV3Mock.sol b/src/mocks/AggregatorV3Mock.sol
new file mode 100644
index 00000000..eff688f6
--- /dev/null
+++ b/src/mocks/AggregatorV3Mock.sol
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {AggregatorV3Interface} from "../rules/interfaces/AggregatorV3Interface.sol";
+
+/**
+ * @title AggregatorV3Mock — configurable Chainlink data feed test double.
+ * @notice Reports a settable answer, timestamp and decimals, and can be told to revert on
+ * `decimals()` or `latestRoundData()` so the failure paths of the Proof of Reserve rule can be exercised.
+ */
+contract AggregatorV3Mock is AggregatorV3Interface {
+ /**
+ * @notice Decimals reported by the feed.
+ */
+ uint8 private _decimals;
+ /**
+ * @notice The reserve answer returned by `latestRoundData`.
+ */
+ int256 private _answer;
+ /**
+ * @notice Round identifier, bumped on every {setAnswer}.
+ */
+ uint80 private _roundId;
+ /**
+ * @notice Timestamp reported as `updatedAt`; 0 simulates an incomplete round.
+ */
+ uint256 private _updatedAt;
+ /**
+ * @notice When true, `decimals()` reverts.
+ */
+ bool private _revertOnDecimals;
+ /**
+ * @notice When true, `latestRoundData()` reverts.
+ */
+ bool private _revertOnLatestRoundData;
+
+ /**
+ * @notice Deploys the mock with an initial answer and decimals.
+ * @param decimals_ Decimals reported by the feed.
+ * @param answer_ Initial reserve answer.
+ */
+ constructor(uint8 decimals_, int256 answer_) {
+ _decimals = decimals_;
+ _answer = answer_;
+ _roundId = 1;
+ _updatedAt = block.timestamp;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets the answer reported by the feed and refreshes `updatedAt` to the current block.
+ * @param answer_ The new answer.
+ */
+ function setAnswer(int256 answer_) external {
+ _answer = answer_;
+ _roundId += 1;
+ _updatedAt = block.timestamp;
+ }
+
+ /**
+ * @notice Sets the timestamp reported as `updatedAt`, without touching the answer.
+ * @param updatedAt_ The new timestamp; 0 simulates an incomplete round.
+ */
+ function setUpdatedAt(uint256 updatedAt_) external {
+ _updatedAt = updatedAt_;
+ }
+
+ /**
+ * @notice Sets the decimals reported by the feed.
+ * @param decimals_ The new decimals.
+ */
+ function setDecimals(uint8 decimals_) external {
+ _decimals = decimals_;
+ }
+
+ /**
+ * @notice Makes `decimals()` revert, simulating a feed that does not honour the interface.
+ * @param shouldRevert True to revert on the next `decimals()` call.
+ */
+ function setRevertOnDecimals(bool shouldRevert) external {
+ _revertOnDecimals = shouldRevert;
+ }
+
+ /**
+ * @notice Makes `latestRoundData()` revert, simulating an unavailable feed.
+ * @param shouldRevert True to revert on the next `latestRoundData()` call.
+ */
+ function setRevertOnLatestRoundData(bool shouldRevert) external {
+ _revertOnLatestRoundData = shouldRevert;
+ }
+
+ /**
+ * @inheritdoc AggregatorV3Interface
+ */
+ function decimals() external view override returns (uint8) {
+ require(!_revertOnDecimals, AggregatorV3Mock_Unavailable());
+ return _decimals;
+ }
+
+ /**
+ * @inheritdoc AggregatorV3Interface
+ */
+ function description() external pure override returns (string memory) {
+ return "AggregatorV3Mock";
+ }
+
+ /**
+ * @inheritdoc AggregatorV3Interface
+ */
+ function version() external pure override returns (uint256) {
+ return 3;
+ }
+
+ /**
+ * @inheritdoc AggregatorV3Interface
+ */
+ function getRoundData(uint80 roundId_)
+ external
+ view
+ override
+ returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
+ {
+ return (roundId_, _answer, _updatedAt, _updatedAt, roundId_);
+ }
+
+ /**
+ * @inheritdoc AggregatorV3Interface
+ */
+ function latestRoundData()
+ external
+ view
+ override
+ returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound)
+ {
+ require(!_revertOnLatestRoundData, AggregatorV3Mock_Unavailable());
+ return (_roundId, _answer, _updatedAt, _updatedAt, _roundId);
+ }
+
+ error AggregatorV3Mock_Unavailable();
+}
diff --git a/src/mocks/BalanceOfMock.sol b/src/mocks/BalanceOfMock.sol
new file mode 100644
index 00000000..afa755ef
--- /dev/null
+++ b/src/mocks/BalanceOfMock.sol
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title BalanceOfMock — test double exposing settable per-address balances
+ * @notice Stores balances that tests can set and read back, and can be made to revert on demand so
+ * the revert-free read path of {RuleMaxBalanceBase} can be exercised.
+ */
+contract BalanceOfMock {
+ /**
+ * @notice Error raised by {balanceOf} while the mock is in its reverting mode.
+ */
+ error BalanceOfMock_Reverting();
+
+ /**
+ * @notice Stored balance per address.
+ */
+ mapping(address => uint256) private _balances;
+ /**
+ * @notice When true, `balanceOf` reverts, simulating a token that broke after configuration.
+ */
+ bool public reverting;
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets the balance of an address.
+ * @param account The address whose balance is set.
+ * @param newBalance The balance to store.
+ */
+ function setBalance(address account, uint256 newBalance) external {
+ _balances[account] = newBalance;
+ }
+
+ /**
+ * @notice Makes subsequent `balanceOf` calls revert, or stops them reverting.
+ * @param newReverting True to make `balanceOf` revert.
+ */
+ function setReverting(bool newReverting) external {
+ reverting = newReverting;
+ }
+
+ /**
+ * @notice Returns the stored balance of an address.
+ * @param account The address to query.
+ * @return The stored balance.
+ */
+ function balanceOf(address account) external view returns (uint256) {
+ require(!reverting, BalanceOfMock_Reverting());
+ return _balances[account];
+ }
+}
diff --git a/src/mocks/ERC3643TokenMock.sol b/src/mocks/ERC3643TokenMock.sol
new file mode 100644
index 00000000..832663d4
--- /dev/null
+++ b/src/mocks/ERC3643TokenMock.sol
@@ -0,0 +1,334 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC734KeyHasPurpose, IIdentityRegistryERC3643} from "../registry/interfaces/IIdentityRegistryERC3643.sol";
+
+/**
+ * @title IERC3643ComplianceForToken — the compliance surface `Token.sol` calls.
+ * @dev Declared here rather than reused from `IERC3643ComplianceFull`, which exists solely to
+ * compute an ERC-165 interface ID and is explicitly not meant to be used as a type.
+ */
+interface IERC3643ComplianceForToken {
+ /**
+ * @notice Binds a token to this compliance contract.
+ * @param token The token being bound.
+ */
+ function bindToken(address token) external;
+
+ /**
+ * @notice Unbinds a previously bound token.
+ * @param token The token being unbound.
+ */
+ function unbindToken(address token) external;
+
+ /**
+ * @notice Notifies the compliance contract that a transfer has occurred.
+ * @param from The sender.
+ * @param to The recipient.
+ * @param value The amount moved.
+ */
+ function transferred(address from, address to, uint256 value) external;
+
+ /**
+ * @notice Notifies the compliance contract that tokens have been minted.
+ * @param to The recipient.
+ * @param value The amount minted.
+ */
+ function created(address to, uint256 value) external;
+
+ /**
+ * @notice Notifies the compliance contract that tokens have been burned.
+ * @param from The holder burned from.
+ * @param value The amount burned.
+ */
+ function destroyed(address from, uint256 value) external;
+
+ /**
+ * @notice Returns whether a transfer is allowed.
+ * @param from The sender, or the zero address for a mint.
+ * @param to The recipient.
+ * @param value The amount to move.
+ * @return True when the transfer is allowed.
+ */
+ function canTransfer(address from, address to, uint256 value) external view returns (bool);
+}
+
+/**
+ * @title ERC3643TokenMock -- a minimal ERC-3643 token reproducing the identity-registry call
+ * sequences of the reference implementation.
+ * @notice The registry-facing logic of `transfer`, `transferFrom`, `forcedTransfer`, `mint`, `burn`
+ * and `recoveryAddress` is transcribed from ERC-3643's vendored `Token.sol`, call order and revert
+ * strings included, so a registry that satisfies this mock satisfies the real token.
+ *
+ * @dev The real `Token.sol` does not compile here: it imports the un-vendored ONCHAINID package and
+ * targets OZ v4 upgradeable, while this repo vendors OZ v5. The mock keeps the registry interaction
+ * faithful and drops the compliance module, pausing and partial-freeze accounting.
+ *
+ * WARNING: test scaffolding only. Not a compliant ERC-3643 token, not for production.
+ *
+ * NOTE: the linter suppressions are deliberate -- keeping the reference parameter names and the
+ * plain `keccak256(abi.encode(...))` key derivation is the point. {IdentityRegistryWhitelistBase}
+ * derives the same key in assembly, and the tests only prove the two agree because this side
+ * computes it the ordinary way. Rewriting it would make the cross-check circular.
+ */
+contract ERC3643TokenMock {
+ /**
+ * @notice The identity registry this token consults on every inbound transfer.
+ */
+ IIdentityRegistryERC3643 public identityRegistry;
+ /**
+ * @notice The compliance contract, i.e. a `RuleEngine`.
+ * @dev Optional here, unlike the real token which requires one: when unset the compliance calls
+ * are skipped, so identity-registry tests can run without wiring an engine. Every call that IS
+ * made follows `Token.sol` exactly.
+ */
+ IERC3643ComplianceForToken public compliance;
+
+ /**
+ * @notice Token balance per account.
+ */
+ mapping(address account => uint256 balance) public balanceOf;
+ /**
+ * @notice Whether an account holds the ERC-3643 agent role.
+ */
+ mapping(address account => bool isAgent) public isAgent;
+ /**
+ * @notice Total token supply.
+ */
+ uint256 public totalSupply;
+
+ /**
+ * @notice Emitted on every balance movement, including mint and burn.
+ * @param from The sender, or the zero address for a mint.
+ * @param to The recipient, or the zero address for a burn.
+ * @param value The amount moved.
+ */
+ event Transfer(address indexed from, address indexed to, uint256 value);
+ /**
+ * @notice Emitted when a position is recovered onto a replacement wallet.
+ * @param lostWallet The wallet recovered from.
+ * @param newWallet The replacement wallet.
+ * @param investorOnchainId The identity contract that vouched for the replacement wallet.
+ */
+ event RecoverySuccess(address indexed lostWallet, address indexed newWallet, address indexed investorOnchainId);
+
+ error ERC3643TokenMock_OnlyAgent();
+
+ /**
+ * @param identityRegistry_ The registry this token consults.
+ * @param agent The address granted the agent role.
+ */
+ constructor(IIdentityRegistryERC3643 identityRegistry_, address agent) {
+ identityRegistry = identityRegistry_;
+ isAgent[agent] = true;
+ }
+
+ // forge-lint: disable-next-line(unwrapped-modifier-logic)
+ modifier onlyAgent() {
+ require(isAgent[msg.sender], ERC3643TokenMock_OnlyAgent());
+ _;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets the identity registry, mirroring `Token.setIdentityRegistry`.
+ * @param identityRegistry_ The new registry.
+ */
+ function setIdentityRegistry(IIdentityRegistryERC3643 identityRegistry_) external {
+ identityRegistry = identityRegistry_;
+ }
+
+ /**
+ * @notice Sets the compliance contract, mirroring `Token.setCompliance`.
+ * @dev Transcribed from `Token.sol:515-522`: unbinds the previous compliance, then makes the
+ * **token itself** call `bindToken(address(this))` on the new one. That self-call is why a
+ * `RuleEngine` needs `setTokenSelfBindingApproval(token, true)` first.
+ * @param compliance_ The new compliance contract.
+ */
+ function setCompliance(IERC3643ComplianceForToken compliance_) external {
+ if (address(compliance) != address(0)) {
+ compliance.unbindToken(address(this));
+ }
+ compliance = compliance_;
+ compliance.bindToken(address(this));
+ }
+
+ /**
+ * @notice Grants or revokes the agent role.
+ * @param account The account to update.
+ * @param status True to grant.
+ */
+ function setAgent(address account, bool status) external {
+ isAgent[account] = status;
+ }
+
+ /**
+ * @notice Transfers tokens; the recipient must be verified.
+ * @dev `Token.transfer`: `if (isVerified(_to) && compliance.canTransfer(...))`. The compliance
+ * leg is omitted here; the registry leg is identical.
+ * @param _to Recipient.
+ * @param _amount Amount to transfer.
+ * @return True on success.
+ */
+ function transfer(address _to, uint256 _amount) external returns (bool) {
+ require(_amount <= balanceOf[msg.sender], "Insufficient Balance");
+ if (identityRegistry.isVerified(_to) && _canTransfer(msg.sender, _to, _amount)) {
+ _transfer(msg.sender, _to, _amount);
+ _complianceTransferred(msg.sender, _to, _amount);
+ return true;
+ }
+ revert("Transfer not possible");
+ }
+
+ /**
+ * @notice Transfers tokens on behalf of `_from`; the recipient must be verified.
+ * @dev `Token.transferFrom`. Allowance handling is omitted; the registry leg is identical.
+ * @param _from Sender.
+ * @param _to Recipient.
+ * @param _amount Amount to transfer.
+ * @return True on success.
+ */
+ function transferFrom(address _from, address _to, uint256 _amount) external returns (bool) {
+ require(_amount <= balanceOf[_from], "Insufficient Balance");
+ if (identityRegistry.isVerified(_to) && _canTransfer(_from, _to, _amount)) {
+ _transfer(_from, _to, _amount);
+ _complianceTransferred(_from, _to, _amount);
+ return true;
+ }
+ revert("Transfer not possible");
+ }
+
+ /**
+ * @notice Mints tokens; the recipient must be verified.
+ * @dev `Token.mint`: `require(isVerified(_to), "Identity is not verified.")`.
+ * @param _to Recipient.
+ * @param _amount Amount to mint.
+ */
+ function mint(address _to, uint256 _amount) external onlyAgent {
+ require(identityRegistry.isVerified(_to), "Identity is not verified.");
+ require(_canTransfer(address(0), _to, _amount), "Compliance not followed");
+ balanceOf[_to] += _amount;
+ totalSupply += _amount;
+ emit Transfer(address(0), _to, _amount);
+ if (address(compliance) != address(0)) {
+ compliance.created(_to, _amount);
+ }
+ }
+
+ /**
+ * @notice Burns tokens.
+ * @dev `Token.burn` makes **no** registry call: a de-listed holder can still be burned out.
+ * @param _userAddress Holder to burn from.
+ * @param _amount Amount to burn.
+ */
+ function burn(address _userAddress, uint256 _amount) external onlyAgent {
+ require(balanceOf[_userAddress] >= _amount, "cannot burn more than balance");
+ balanceOf[_userAddress] -= _amount;
+ totalSupply -= _amount;
+ emit Transfer(_userAddress, address(0), _amount);
+ if (address(compliance) != address(0)) {
+ compliance.destroyed(_userAddress, _amount);
+ }
+ }
+
+ /**
+ * @notice Moves an investor's position to a replacement wallet.
+ * @dev Transcribed from `Token.recoveryAddress`, preserving the order that matters:
+ * 1. `keyHasPurpose(keccak256(abi.encode(_newWallet)), 1)` on the **caller-supplied**
+ * `_investorOnchainId` -- reverts "Recovery not possible" when false;
+ * 2. `investorCountry(_lostWallet)` read from the registry;
+ * 3. `registerIdentity(_newWallet, _investorOnchainId, country)` -- called BY THE TOKEN;
+ * 4. `forcedTransfer(_lostWallet, _newWallet, balance)`;
+ * 5. `deleteIdentity(_lostWallet)` -- also called BY THE TOKEN.
+ * @param _lostWallet The wallet to recover from.
+ * @param _newWallet The replacement wallet.
+ * @param _investorOnchainId The contract answering `keyHasPurpose`.
+ * @return True on success.
+ */
+ function recoveryAddress(address _lostWallet, address _newWallet, address _investorOnchainId)
+ external
+ onlyAgent
+ returns (bool)
+ {
+ require(balanceOf[_lostWallet] != 0, "no tokens to recover");
+ // forge-lint: disable-next-line(asm-keccak256)
+ bytes32 _key = keccak256(abi.encode(_newWallet));
+ if (IERC734KeyHasPurpose(_investorOnchainId).keyHasPurpose(_key, 1)) {
+ uint256 investorTokens = balanceOf[_lostWallet];
+ identityRegistry.registerIdentity(
+ _newWallet, _investorOnchainId, identityRegistry.investorCountry(_lostWallet)
+ );
+ forcedTransfer(_lostWallet, _newWallet, investorTokens);
+ identityRegistry.deleteIdentity(_lostWallet);
+ emit RecoverySuccess(_lostWallet, _newWallet, _investorOnchainId);
+ return true;
+ }
+ revert("Recovery not possible");
+ }
+
+ /**
+ * @notice Agent-forced transfer; the recipient must still be verified.
+ * @dev `Token.forcedTransfer`: bypasses freezes but NOT the registry check on `_to`.
+ * @param _from Sender.
+ * @param _to Recipient.
+ * @param _amount Amount to transfer.
+ * @return True on success.
+ */
+ function forcedTransfer(address _from, address _to, uint256 _amount) public onlyAgent returns (bool) {
+ require(balanceOf[_from] >= _amount, "sender balance too low");
+ // NOTE: `Token.forcedTransfer` does NOT consult `canTransfer` -- it only notifies
+ // `transferred` afterwards. A compliance contract that reverts in `transferred` (as a
+ // RuleEngine does) still blocks the move; one that only answers `canTransfer` does not.
+ if (identityRegistry.isVerified(_to)) {
+ _transfer(_from, _to, _amount);
+ _complianceTransferred(_from, _to, _amount);
+ return true;
+ }
+ revert("Transfer not possible");
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Notifies the compliance contract that a transfer happened; no-op when none is set.
+ * @param from Sender.
+ * @param to Recipient.
+ * @param amount Amount moved.
+ */
+ function _complianceTransferred(address from, address to, uint256 amount) internal {
+ if (address(compliance) != address(0)) {
+ compliance.transferred(from, to, amount);
+ }
+ }
+
+ /**
+ * @notice Moves value between two balances.
+ * @param from Sender.
+ * @param to Recipient.
+ * @param amount Amount to move.
+ */
+ function _transfer(address from, address to, uint256 amount) internal {
+ balanceOf[from] -= amount;
+ balanceOf[to] += amount;
+ emit Transfer(from, to, amount);
+ }
+
+ /**
+ * @notice Asks the compliance contract whether a move is allowed; true when none is set.
+ * @param from Sender, or the zero address for a mint.
+ * @param to Recipient.
+ * @param amount Amount to move.
+ * @return True when the move is allowed.
+ */
+ function _canTransfer(address from, address to, uint256 amount) internal view returns (bool) {
+ if (address(compliance) == address(0)) {
+ return true;
+ }
+ return compliance.canTransfer(from, to, amount);
+ }
+}
diff --git a/src/mocks/OnchainIdMock.sol b/src/mocks/OnchainIdMock.sol
new file mode 100644
index 00000000..ac8390f1
--- /dev/null
+++ b/src/mocks/OnchainIdMock.sol
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC734KeyHasPurpose} from "../registry/interfaces/IIdentityRegistryERC3643.sol";
+
+/**
+ * @title OnchainIdMock — the minimal ERC-734 surface `recoveryAddress` needs.
+ * @notice `Token.recoveryAddress` calls `keyHasPurpose(keccak256(abi.encode(newWallet)), 1)` on the
+ * `_investorOnchainID` address the agent supplies. In production that is the investor's ONCHAINID.
+ * This stub stands in for it, letting a test decide which wallet keys it vouches for.
+ *
+ * @dev It records keys rather than returning a blanket `true` so the tests exercise the same shape
+ * as a real identity: recovery succeeds only for a wallet the identity actually vouches for.
+ *
+ * WARNING: test scaffolding only. Holds no real keys and performs no authorisation.
+ */
+contract OnchainIdMock is IERC734KeyHasPurpose {
+ /**
+ * @notice Keys this identity vouches for, per ERC-734 purpose.
+ */
+ mapping(bytes32 key => mapping(uint256 purpose => bool held)) private _keys;
+
+ /**
+ * @notice Vouches for a wallet, as an ONCHAINID holding a management key for it would.
+ * @param wallet The wallet to vouch for.
+ * @param purpose The ERC-734 purpose to grant (1 = MANAGEMENT).
+ */
+ function addWalletKey(address wallet, uint256 purpose) external {
+ _keys[keccak256(abi.encode(wallet))][purpose] = true;
+ }
+
+ /**
+ * @inheritdoc IERC734KeyHasPurpose
+ */
+ function keyHasPurpose(bytes32 _key, uint256 _purpose) external view override returns (bool) {
+ return _keys[_key][_purpose];
+ }
+}
diff --git a/src/mocks/TotalSupplyDecimalsMock.sol b/src/mocks/TotalSupplyDecimalsMock.sol
new file mode 100644
index 00000000..3f39db5b
--- /dev/null
+++ b/src/mocks/TotalSupplyDecimalsMock.sol
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title TotalSupplyDecimalsMock — test double exposing a settable total supply and fixed decimals.
+ * @notice Same as {TotalSupplyMock} but it also implements `decimals()`, so the decimals
+ * cross-check performed when configuring a rule can be exercised.
+ */
+contract TotalSupplyDecimalsMock {
+ /**
+ * @notice The stored total supply value.
+ */
+ uint256 private _totalSupply;
+ /**
+ * @notice Decimals reported by the token; fixed at construction.
+ */
+ uint8 private immutable _DECIMALS;
+ /**
+ * @notice When true, `totalSupply()` reverts.
+ */
+ bool private _revertOnTotalSupply;
+
+ /**
+ * @notice Deploys the mock with the given decimals and a zero total supply.
+ * @param decimals_ Decimals reported by the token.
+ */
+ constructor(uint8 decimals_) {
+ _DECIMALS = decimals_;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets the total supply value.
+ * @param newTotalSupply The new total supply to store.
+ */
+ function setTotalSupply(uint256 newTotalSupply) external {
+ _totalSupply = newTotalSupply;
+ }
+
+ /**
+ * @notice Makes `totalSupply()` revert, simulating a token that breaks after configuration.
+ * @param shouldRevert True to revert on the next `totalSupply()` call.
+ */
+ function setRevertOnTotalSupply(bool shouldRevert) external {
+ _revertOnTotalSupply = shouldRevert;
+ }
+
+ /**
+ * @notice Returns the stored total supply.
+ * @return The current total supply value.
+ */
+ function totalSupply() external view returns (uint256) {
+ require(!_revertOnTotalSupply, TotalSupplyDecimalsMock_Unavailable());
+ return _totalSupply;
+ }
+
+ /**
+ * @notice Returns the stored decimals.
+ * @return The current decimals value.
+ */
+ function decimals() external view returns (uint8) {
+ return _DECIMALS;
+ }
+
+ error TotalSupplyDecimalsMock_Unavailable();
+}
diff --git a/src/mocks/harness/RuleReceiverWhitelistHarnesses.sol b/src/mocks/harness/RuleReceiverWhitelistHarnesses.sol
new file mode 100644
index 00000000..04d50430
--- /dev/null
+++ b/src/mocks/harness/RuleReceiverWhitelistHarnesses.sol
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleReceiverWhitelist} from "../../rules/validation/deployment/RuleReceiverWhitelist.sol";
+import {
+ RuleReceiverWhitelistOwnable2Step
+} from "../../rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol";
+
+/**
+ * @title RuleReceiverWhitelistHarness — test harness exposing RuleReceiverWhitelist internals
+ */
+contract RuleReceiverWhitelistHarness is RuleReceiverWhitelist {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the harness forwarding to the RuleReceiverWhitelist constructor
+ * @param admin Address granted the admin role
+ * @param forwarderIrrevocable Trusted ERC-2771 forwarder address
+ */
+ constructor(address admin, address forwarderIrrevocable) RuleReceiverWhitelist(admin, forwarderIrrevocable) {}
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Exposes the internal `_msgSender()` resolved sender
+ * @return Address returned by `_msgSender()`
+ */
+ function exposedMsgSender() external view returns (address) {
+ return _msgSender();
+ }
+
+ /**
+ * @notice Exposes the internal `_msgData()` calldata buffer
+ * @return Calldata bytes returned by `_msgData()`
+ */
+ function exposedMsgData() external view returns (bytes memory) {
+ return _msgData();
+ }
+
+ /**
+ * @notice Exposes the internal `_contextSuffixLength()` value
+ * @return Length in bytes of the ERC-2771 context suffix
+ */
+ function exposedContextSuffixLength() external view returns (uint256) {
+ return _contextSuffixLength();
+ }
+}
+
+/**
+ * @title RuleReceiverWhitelistOwnable2StepHarness — test harness exposing RuleReceiverWhitelistOwnable2Step internals
+ */
+contract RuleReceiverWhitelistOwnable2StepHarness is RuleReceiverWhitelistOwnable2Step {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the harness forwarding to the RuleReceiverWhitelistOwnable2Step constructor
+ * @param owner Address set as the contract owner
+ * @param forwarderIrrevocable Trusted ERC-2771 forwarder address
+ */
+ constructor(address owner, address forwarderIrrevocable)
+ RuleReceiverWhitelistOwnable2Step(owner, forwarderIrrevocable)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Exposes the internal `_msgSender()` resolved sender
+ * @return Address returned by `_msgSender()`
+ */
+ function exposedMsgSender() external view returns (address) {
+ return _msgSender();
+ }
+
+ /**
+ * @notice Exposes the internal `_msgData()` calldata buffer
+ * @return Calldata bytes returned by `_msgData()`
+ */
+ function exposedMsgData() external view returns (bytes memory) {
+ return _msgData();
+ }
+
+ /**
+ * @notice Exposes the internal `_contextSuffixLength()` value
+ * @return Length in bytes of the ERC-2771 context suffix
+ */
+ function exposedContextSuffixLength() external view returns (uint256) {
+ return _contextSuffixLength();
+ }
+}
diff --git a/src/mocks/harness/SanctionsListDelegationHarness.sol b/src/mocks/harness/SanctionsListDelegationHarness.sol
new file mode 100644
index 00000000..00161f5e
--- /dev/null
+++ b/src/mocks/harness/SanctionsListDelegationHarness.sol
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {ISanctionsList} from "../../rules/interfaces/ISanctionsList.sol";
+import {RuleSanctionsList} from "../../rules/validation/deployment/RuleSanctionsList.sol";
+
+/**
+ * @title SanctionsListExtraCheckHarness
+ * @notice A subclass that adds a screening check which does NOT depend on the sanctions oracle
+ * (`CLAUDE_ANALYSIS.md` F-2).
+ * @dev This is the shape that exposes the defect. `_detectTransferRestrictionFrom` used to nest its
+ * delegation to {_detectTransferRestriction} inside the `oracle != address(0)` branch, so with
+ * no oracle configured the `transferFrom` path returned `TRANSFER_OK` without ever calling the
+ * hook -- and this subclass's check silently did not apply there, while it did apply to a plain
+ * `transfer`. A compliance rule that screens one entrypoint and not the other is the failure
+ * this harness exists to catch.
+ */
+contract SanctionsListExtraCheckHarness is RuleSanctionsList {
+ /**
+ * @notice Restriction code returned for the extra, oracle-independent check.
+ */
+ uint8 public constant CODE_EXTRA_BLOCKED = 201;
+
+ /**
+ * @notice Address this subclass blocks regardless of what the oracle says.
+ */
+ address public immutable BLOCKED;
+
+ constructor(address admin, address forwarderIrrevocable, ISanctionsList oracle_, address blocked)
+ RuleSanctionsList(admin, forwarderIrrevocable, oracle_)
+ {
+ BLOCKED = blocked;
+ }
+
+ /**
+ * @notice Applies the base sanctions screening, then the extra oracle-independent check.
+ */
+ function _detectTransferRestriction(address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ uint8 code = super._detectTransferRestriction(from, to, value);
+ if (code != uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK)) {
+ return code;
+ }
+ if (from == BLOCKED || to == BLOCKED) {
+ return CODE_EXTRA_BLOCKED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+}
diff --git a/src/mocks/harness/VirtualHookOverrideHarnesses.sol b/src/mocks/harness/VirtualHookOverrideHarnesses.sol
new file mode 100644
index 00000000..3e07d42c
--- /dev/null
+++ b/src/mocks/harness/VirtualHookOverrideHarnesses.sol
@@ -0,0 +1,260 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {RuleBlacklist} from "../../rules/validation/deployment/RuleBlacklist.sol";
+import {RuleConditionalTransferLight} from "../../rules/operation/RuleConditionalTransferLight.sol";
+import {RuleERC2980} from "../../rules/validation/deployment/RuleERC2980.sol";
+import {RuleIdentityRegistry} from "../../rules/validation/deployment/RuleIdentityRegistry.sol";
+import {RuleMaxTotalSupply} from "../../rules/validation/deployment/RuleMaxTotalSupply.sol";
+
+/**
+ * @title VirtualHookOverrideHarnesses
+ * @notice Proves that the `virtual` convention actually holds for functions that were previously
+ * non-`virtual` and therefore impossible to override (`CLAUDE_ANALYSIS.md` E-1, E-2, E-3).
+ * @dev These contracts exist to be compiled: dropping `virtual` from any function they override
+ * makes the whole project fail to build, which is the only way a convention with no runtime
+ * behaviour can be regression-tested. The accompanying tests additionally check that the
+ * override is reached, so this is not merely a compile-time assertion.
+ *
+ * For E-3 the coverage is deliberately REPRESENTATIVE, not exhaustive: one function is
+ * overridden per family (address-set write, ERC-2980 list write, rule configuration setter,
+ * approval write, token-facing `transferred` hook, binding). Overriding all 27 would add bulk
+ * without adding signal, because the compiler applies `virtual` per function, not per family --
+ * so a regression on an uncovered sibling would still slip through. That residual gap is the
+ * known cost of the sampling.
+ */
+
+/**
+ * @notice Overrides the transfer-execution authorization hook with an allow-list of one address.
+ * @dev Before the hook was made `virtual`, a subclass could not change who may consume approvals --
+ * the most likely customization point on this rule.
+ */
+contract ConditionalTransferLightCustomExecutorHarness is RuleConditionalTransferLight {
+ /**
+ * @notice The only address permitted to execute approved transfers.
+ */
+ address public immutable SOLE_EXECUTOR;
+
+ /**
+ * @notice Raised when a caller other than {SOLE_EXECUTOR} tries to execute a transfer.
+ */
+ error NotTheSoleExecutor(address caller);
+
+ constructor(address admin, address soleExecutor) RuleConditionalTransferLight(admin) {
+ SOLE_EXECUTOR = soleExecutor;
+ }
+
+ /**
+ * @notice Counts calls that reached the overridden public entrypoints (`CLAUDE_ANALYSIS.md` E-3).
+ */
+ uint256 public approveTransferOverrideCalls;
+ /**
+ * @notice Counts calls that reached the overridden `transferred` hook (`CLAUDE_ANALYSIS.md` E-3).
+ */
+ uint256 public transferredOverrideCalls;
+
+ /**
+ * @notice Replaces the bound-token / bound-engine policy with a single hard-coded executor.
+ */
+ function _authorizeTransferExecution() internal view virtual override {
+ require(_msgSender() == SOLE_EXECUTOR, NotTheSoleExecutor(_msgSender()));
+ }
+
+ /**
+ * @notice Records that the override ran, then defers to the base approval logic.
+ */
+ function approveTransfer(address from, address to, uint256 value) public virtual override {
+ ++approveTransferOverrideCalls;
+ super.approveTransfer(from, to, value);
+ }
+
+ /**
+ * @notice Records that the override ran, then defers to the base compliance hook.
+ * @dev The token-facing `transferred` entrypoints were among the least overridable functions in
+ * the library before E-3.
+ */
+ function transferred(address from, address to, uint256 value) public virtual override {
+ ++transferredOverrideCalls;
+ super.transferred(from, to, value);
+ }
+}
+
+/**
+ * @notice Overrides a rule-configuration setter (`CLAUDE_ANALYSIS.md` E-3).
+ * @dev `setMaxTotalSupply` was non-`virtual` while the equivalent setters on the sibling
+ * `RuleChainlinkPoR` were `virtual` -- the inconsistency E-3 calls out.
+ */
+contract MaxTotalSupplyCappedSetterHarness is RuleMaxTotalSupply {
+ /**
+ * @notice Hard ceiling this subclass refuses to raise the cap above.
+ */
+ uint256 public constant HARD_CEILING = 1_000_000;
+
+ /**
+ * @notice Raised when a caller tries to set a cap above {HARD_CEILING}.
+ */
+ error AboveHardCeiling(uint256 requested);
+
+ constructor(address admin, address tokenContract_, uint256 maxTotalSupply_)
+ RuleMaxTotalSupply(admin, tokenContract_, maxTotalSupply_)
+ {}
+
+ /**
+ * @notice Adds a ceiling the base setter does not have.
+ */
+ function setMaxTotalSupply(uint256 newMaxTotalSupply) public virtual override {
+ require(newMaxTotalSupply <= HARD_CEILING, AboveHardCeiling(newMaxTotalSupply));
+ super.setMaxTotalSupply(newMaxTotalSupply);
+ }
+}
+
+/**
+ * @notice Overrides an identity-registry configuration setter (`CLAUDE_ANALYSIS.md` E-3).
+ */
+contract IdentityRegistryPinnedHarness is RuleIdentityRegistry {
+ /**
+ * @notice Raised when any attempt is made to repoint the registry.
+ */
+ error RegistryIsPinned();
+
+ constructor(address admin, address identityRegistry_, bool checkSender_, bool checkSpender_)
+ RuleIdentityRegistry(admin, identityRegistry_, checkSender_, checkSpender_)
+ {}
+
+ /**
+ * @notice Makes the configured registry immutable after deployment.
+ */
+ function setIdentityRegistry(address) public virtual override {
+ revert RegistryIsPinned();
+ }
+}
+
+/**
+ * @notice Overrides an ERC-2980 list write (`CLAUDE_ANALYSIS.md` E-3).
+ * @dev Representative of the eight near-identical list functions on `RuleERC2980Base`.
+ */
+contract ERC2980SelfWhitelistBlockHarness is RuleERC2980 {
+ /**
+ * @notice Raised when the rule itself is whitelisted.
+ */
+ error CannotWhitelistTheRule();
+
+ constructor(address admin, address forwarderIrrevocable, bool allowMintBurn)
+ RuleERC2980(admin, forwarderIrrevocable, allowMintBurn)
+ {}
+
+ /**
+ * @notice Rejects whitelisting this contract, then defers to the base implementation.
+ */
+ function addWhitelistAddress(address targetAddress) public virtual override {
+ require(targetAddress != address(this), CannotWhitelistTheRule());
+ super.addWhitelistAddress(targetAddress);
+ }
+}
+
+/**
+ * @notice Overrides the blacklist's restriction hook to also reject a single quarantined address.
+ * @dev Exercises `_detectTransferRestriction` and `_detectTransferRestrictionFrom`, both of which
+ * were non-`virtual` on this rule.
+ */
+contract BlacklistQuarantineHarness is RuleBlacklist {
+ /**
+ * @notice Restriction code returned for the quarantined address.
+ */
+ uint8 public constant CODE_QUARANTINED = 200;
+
+ /**
+ * @notice Address rejected in addition to whatever the blacklist itself decides.
+ */
+ address public immutable QUARANTINED;
+
+ /**
+ * @notice Raised when the quarantined address is passed to the overridden {addAddress}.
+ */
+ error CannotListQuarantined();
+
+ constructor(address admin, address forwarderIrrevocable, address quarantined)
+ RuleBlacklist(admin, forwarderIrrevocable)
+ {
+ QUARANTINED = quarantined;
+ }
+
+ /**
+ * @notice Applies the base blacklist check, then the extra quarantine rule.
+ */
+ function _detectTransferRestriction(address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ uint8 code = super._detectTransferRestriction(from, to, value);
+ if (code != uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK)) {
+ return code;
+ }
+ if (from == QUARANTINED || to == QUARANTINED) {
+ return CODE_QUARANTINED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /**
+ * @notice Applies the base spender check, then the extra quarantine rule to the spender.
+ */
+ function _detectTransferRestrictionFrom(address spender, address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ uint8 code = super._detectTransferRestrictionFrom(spender, from, to, value);
+ if (code != uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK)) {
+ return code;
+ }
+ if (spender == QUARANTINED) {
+ return CODE_QUARANTINED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /**
+ * @notice Hard-denies the fungible `canTransfer` view (`CLAUDE_ANALYSIS.md` E-2).
+ * @dev Deliberately contradicts {detectTransferRestriction} so the test can prove the override is
+ * what answers, rather than the inherited implementation.
+ */
+ function canTransfer(address from, address to, uint256 amount) public view virtual override returns (bool isValid) {
+ from;
+ to;
+ amount;
+ return false;
+ }
+
+ /**
+ * @notice Hard-denies the ERC-7943 `canTransfer` overload (`CLAUDE_ANALYSIS.md` E-2).
+ */
+ function canTransfer(address from, address to, uint256 tokenId, uint256 amount)
+ public
+ view
+ virtual
+ override
+ returns (bool)
+ {
+ from;
+ to;
+ tokenId;
+ amount;
+ return false;
+ }
+
+ /**
+ * @notice Rejects listing the quarantined address, then defers to the base set write.
+ * @dev Representative of the four `RuleAddressSet` write functions (`CLAUDE_ANALYSIS.md` E-3).
+ */
+ function addAddress(address targetAddress) public virtual override {
+ require(targetAddress != QUARANTINED, CannotListQuarantined());
+ super.addAddress(targetAddress);
+ }
+}
diff --git a/src/modules/VersionModule.sol b/src/modules/VersionModule.sol
index 675192fc..03806432 100644
--- a/src/modules/VersionModule.sol
+++ b/src/modules/VersionModule.sol
@@ -11,7 +11,7 @@ abstract contract VersionModule is IERC3643Version {
/**
* @notice The contract version string returned by {version}.
*/
- string private constant VERSION = "0.4.0";
+ string private constant VERSION = "0.5.0";
/*//////////////////////////////////////////////////////////////
PUBLIC FUNCTIONS
@@ -20,7 +20,7 @@ abstract contract VersionModule is IERC3643Version {
/**
* @inheritdoc IERC3643Version
*/
- function version() public view virtual override returns (string memory version_) {
+ function version() public pure virtual override returns (string memory version_) {
return VERSION;
}
}
diff --git a/src/registry/IdentityRegistryWhitelist.sol b/src/registry/IdentityRegistryWhitelist.sol
new file mode 100644
index 00000000..3e89acd0
--- /dev/null
+++ b/src/registry/IdentityRegistryWhitelist.sol
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {AccessControlModuleStandalone} from "../modules/AccessControlModuleStandalone.sol";
+import {IdentityRegistryWhitelistBase} from "./abstract/IdentityRegistryWhitelistBase.sol";
+
+/**
+ * @title IdentityRegistryWhitelist
+ * @notice A whitelist that plugs directly into an ERC-3643 token as its identity registry.
+ * @dev Install with `token.setIdentityRegistry(address(this))`. Grant {IDENTITY_REGISTRAR_ROLE} to
+ * the operator that maintains the whitelist **and to the token itself**, otherwise
+ * `recoveryAddress` reverts -- see the technical doc.
+ *
+ * This is not a compliance rule: it implements no `IRule` surface and must not be added to a
+ * `RuleEngine`.
+ */
+contract IdentityRegistryWhitelist is AccessControlModuleStandalone, IdentityRegistryWhitelistBase {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param admin Address that receives the default admin role.
+ */
+ constructor(address admin) AccessControlModuleStandalone(admin) {}
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts identity registration and deletion to IDENTITY_REGISTRAR_ROLE.
+ */
+ function _authorizeIdentityRegistrar() internal view virtual override onlyRole(IDENTITY_REGISTRAR_ROLE) {}
+}
diff --git a/src/registry/abstract/IdentityRegistryWhitelistBase.sol b/src/registry/abstract/IdentityRegistryWhitelistBase.sol
new file mode 100644
index 00000000..f45ba82e
--- /dev/null
+++ b/src/registry/abstract/IdentityRegistryWhitelistBase.sol
@@ -0,0 +1,128 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleAddressSetInternal} from "../../rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol";
+import {IdentityRegistryWhitelistInvariantStorage} from "./IdentityRegistryWhitelistInvariantStorage.sol";
+import {VersionModule} from "../../modules/VersionModule.sol";
+import {IIdentityRegistryERC3643} from "../interfaces/IIdentityRegistryERC3643.sol";
+
+/**
+ * @title IdentityRegistryWhitelistBase
+ * @notice A whitelist that presents itself to an ERC-3643 token as an identity registry.
+ * @dev Installed with `token.setIdentityRegistry(address(this))`. **Not** a compliance rule: no
+ * `IRule` surface, and it must never be added to a `RuleEngine`.
+ *
+ * @dev **No identity data is stored** -- no ONCHAINID, no country, no claims. `registerIdentity`'s
+ * `_identity` and `_country` are accepted so the ERC-3643 signature matches, then discarded, and
+ * {investorCountry} always returns 0. Verification means one thing here: is this wallet listed.
+ * `Token.sol` reads `investorCountry` only in `recoveryAddress`, to pass it straight back, so the
+ * token is unaffected; a *custom* compliance module reading it would see every investor as country 0.
+ *
+ * @dev **No ERC-734 surface.** `keyHasPurpose` was implemented once and removed: `recoveryAddress`
+ * calls it on the address the agent supplies, never cross-checking it against the registry, so it
+ * gated nothing while costing a reverse index. Supply a real ONCHAINID as `_investorOnchainID`.
+ *
+ * @dev The address set is inherited from {RuleAddressSetInternal}, so the registry *is* the list.
+ * Only the internal layer, so there is one write API (the ERC-3643 one), not two overlapping ones.
+ */
+abstract contract IdentityRegistryWhitelistBase is
+ RuleAddressSetInternal,
+ VersionModule,
+ IIdentityRegistryERC3643,
+ IdentityRegistryWhitelistInvariantStorage
+{
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc IIdentityRegistryERC3643
+ * @dev Adds the wallet to the whitelist. `_identity` is echoed in {IdentityRegistered} for
+ * off-chain traceability and `_country` is ignored entirely -- neither is stored.
+ *
+ * Reverts on the zero address and on an already-registered wallet, matching ERC-3643's
+ * reference registry (which reverts with "address stored already").
+ */
+ function registerIdentity(
+ address _userAddress,
+ address _identity,
+ uint16 /* _country */
+ )
+ external
+ virtual
+ override
+ onlyIdentityRegistrar
+ {
+ // Same guards, same errors as the whitelist rules: the zero address is the mint/burn
+ // sentinel and must never be listed, or `isVerified(address(0))` would return true.
+ require(_userAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
+ require(_addAddress(_userAddress), RuleAddressSet_AddressAlreadyListed());
+ emit IdentityRegistered(_userAddress, _identity);
+ }
+
+ /**
+ * @inheritdoc IIdentityRegistryERC3643
+ * @dev Reverts if the wallet is not registered.
+ */
+ function deleteIdentity(address _userAddress) external virtual override onlyIdentityRegistrar {
+ require(_removeAddress(_userAddress), RuleAddressSet_AddressNotFound());
+ emit IdentityRemoved(_userAddress);
+ }
+
+ /**
+ * @notice Returns how many wallets are registered.
+ * @dev There is deliberately no full enumeration getter, matching `RuleWhitelist` and
+ * `RuleBlacklist`, which expose a count but not the member list.
+ * @return The number of registered wallets.
+ */
+ function registeredIdentityCount() external view virtual returns (uint256) {
+ return _listedAddressCount();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc IIdentityRegistryERC3643
+ * @dev `address(0)` is never registered, so it is never verified -- ERC-3643 defines
+ * `isVerified` as "is this wallet a valid investor", and the zero address is not a wallet.
+ * Mint and burn permission is the token's business, not the registry's.
+ */
+ function isVerified(address _userAddress) public view virtual override returns (bool) {
+ return _isAddressListed(_userAddress);
+ }
+
+ /**
+ * @inheritdoc IIdentityRegistryERC3643
+ * @dev Always returns 0: this registry keeps no identity data, only a whitelist. The function
+ * exists because `recoveryAddress` calls it -- omitting it would make every recovery revert --
+ * and the 0 it returns is handed straight back to {registerIdentity}, which ignores it.
+ */
+ function investorCountry(
+ address /* _userAddress */
+ )
+ public
+ view
+ virtual
+ override
+ returns (uint16)
+ {
+ return 0;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ modifier onlyIdentityRegistrar() {
+ _authorizeIdentityRegistrar();
+ _;
+ }
+
+ /**
+ * @notice Authorizes the caller to register and delete identities; reverts otherwise.
+ * @dev Implemented by concrete subclasses with the desired access-control policy.
+ */
+ function _authorizeIdentityRegistrar() internal view virtual;
+}
diff --git a/src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol b/src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol
new file mode 100644
index 00000000..ae60ce85
--- /dev/null
+++ b/src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title IdentityRegistryWhitelistInvariantStorage — constants, events and errors for the
+ * whitelist-backed ERC-3643 identity registry.
+ */
+abstract contract IdentityRegistryWhitelistInvariantStorage {
+ /* ============ Constants ============ */
+
+ /**
+ * @notice Role allowed to register and delete identities.
+ * @dev The ERC-3643 token itself must hold this role, because `recoveryAddress` makes the token
+ * call `registerIdentity` and `deleteIdentity` on the registry. See the technical doc.
+ */
+ bytes32 public constant IDENTITY_REGISTRAR_ROLE = keccak256("IDENTITY_REGISTRAR_ROLE");
+
+ /* ============ Events ============ */
+
+ /**
+ * @notice Emitted when a wallet is added to the whitelist.
+ * @param userAddress The registered wallet.
+ * @param identity The ONCHAINID passed by the caller. Echoed for off-chain traceability only --
+ * this registry stores no identity data. The `_country` argument is not echoed because it is
+ * ignored entirely.
+ */
+ event IdentityRegistered(address indexed userAddress, address indexed identity);
+ /**
+ * @notice Emitted when a wallet is removed from the registry.
+ * @param userAddress The removed wallet.
+ */
+ event IdentityRemoved(address indexed userAddress);
+
+ /* ============ Errors ============ */
+
+ // The zero-address and not-listed conditions deliberately reuse the address-set errors
+ // (`RuleAddressSet_ZeroAddressNotAllowed`, `RuleAddressSet_AddressNotFound`) inherited with
+ // `RuleAddressSetInternal`, so this registry reverts identically to the whitelist rules.
+}
diff --git a/src/registry/interfaces/IIdentityRegistryERC3643.sol b/src/registry/interfaces/IIdentityRegistryERC3643.sol
new file mode 100644
index 00000000..92c0a5d1
--- /dev/null
+++ b/src/registry/interfaces/IIdentityRegistryERC3643.sol
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title IIdentityRegistryERC3643 — the subset of the ERC-3643 identity registry an ERC-3643 token
+ * actually calls.
+ * @notice ERC-3643's full `IIdentityRegistry` also declares `contains`, `identity`,
+ * `updateIdentity`, `updateCountry`, `batchRegisterIdentity`, `identityStorage`, `issuersRegistry`
+ * and `topicsRegistry`. **None of those are invoked by `Token.sol`**, so they are deliberately left
+ * out here: a registry that implements only this interface is a complete drop-in for a token, and
+ * omitting the rest keeps the contract small and its trust surface obvious.
+ * @dev `_identity` is typed `address` rather than `IIdentity`. That is ABI-identical — Solidity
+ * canonicalises contract types to `address` when computing selectors — so `registerIdentity` here
+ * has exactly the same selector as ERC-3643's, without dragging in the ONCHAINID dependency.
+ */
+interface IIdentityRegistryERC3643 {
+ /**
+ * @notice Registers a wallet as a verified investor.
+ * @dev Called by the token itself inside `recoveryAddress`, and by a registrar off-chain.
+ * @param _userAddress The wallet to register.
+ * @param _identity The investor's ONCHAINID contract.
+ * @param _country The investor's country code (ISO-3166 numeric).
+ */
+ function registerIdentity(address _userAddress, address _identity, uint16 _country) external;
+
+ /**
+ * @notice Removes a wallet from the registry.
+ * @dev Called by the token itself inside `recoveryAddress`.
+ * @param _userAddress The wallet to remove.
+ */
+ function deleteIdentity(address _userAddress) external;
+
+ /**
+ * @notice Returns whether a wallet is a verified investor.
+ * @dev Called by `transfer`, `transferFrom`, `forcedTransfer` and `mint`.
+ * @param _userAddress The wallet to check.
+ * @return True if the wallet is verified.
+ */
+ function isVerified(address _userAddress) external view returns (bool);
+
+ /**
+ * @notice Returns the country code recorded for a wallet.
+ * @dev Called by `recoveryAddress` to carry the country over to the replacement wallet.
+ * @param _userAddress The wallet to query.
+ * @return The country code, or 0 when the wallet is not registered.
+ */
+ function investorCountry(address _userAddress) external view returns (uint16);
+}
+
+/**
+ * @title IERC734KeyHasPurpose — the single ERC-734 getter `recoveryAddress` needs.
+ * @notice `Token.recoveryAddress` calls `keyHasPurpose` on the **caller-supplied**
+ * `_investorOnchainID` argument, not on anything the registry returns. Implementing this lets the
+ * registry itself be passed as that argument, so no ONCHAINID deployment is required.
+ */
+interface IERC734KeyHasPurpose {
+ /**
+ * @notice Returns whether a key holds a given purpose.
+ * @param _key The key, `keccak256(abi.encode(walletAddress))` in ERC-3643's usage.
+ * @param _purpose The ERC-734 purpose (1 = MANAGEMENT).
+ * @return True if the key holds the purpose.
+ */
+ function keyHasPurpose(bytes32 _key, uint256 _purpose) external view returns (bool);
+}
diff --git a/src/rules/interfaces/AggregatorV3Interface.sol b/src/rules/interfaces/AggregatorV3Interface.sol
new file mode 100644
index 00000000..7fc25e8a
--- /dev/null
+++ b/src/rules/interfaces/AggregatorV3Interface.sol
@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title AggregatorV3Interface — Chainlink data feed read interface.
+ * @notice Minimal local copy of the Chainlink `AggregatorV3Interface` used to read a
+ * Proof of Reserve (PoR) data feed. It is redeclared here rather than imported so that
+ * the library does not take a dependency on the Chainlink contracts package; the selectors
+ * are identical to the canonical interface, so any Chainlink aggregator can be cast to it.
+ * @dev Reference: https://docs.chain.link/data-feeds/api-reference
+ */
+interface AggregatorV3Interface {
+ /**
+ * @notice Number of decimals used by the values this feed reports.
+ * @return The feed decimals.
+ */
+ function decimals() external view returns (uint8);
+
+ /**
+ * @notice Human-readable description of the feed, e.g. "WBTC PoR".
+ * @return The feed description.
+ */
+ function description() external view returns (string memory);
+
+ /**
+ * @notice Version number of the aggregator implementation.
+ * @return The aggregator version.
+ */
+ function version() external view returns (uint256);
+
+ /**
+ * @notice Returns the data of a specific round.
+ * @param _roundId Identifier of the round to read.
+ * @return roundId The round identifier.
+ * @return answer The reported value for that round.
+ * @return startedAt Timestamp at which the round started.
+ * @return updatedAt Timestamp at which the round was last updated.
+ * @return answeredInRound Round identifier in which the answer was computed.
+ */
+ function getRoundData(uint80 _roundId)
+ external
+ view
+ returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
+
+ /**
+ * @notice Returns the data of the latest round.
+ * @return roundId The round identifier.
+ * @return answer The reported value for that round.
+ * @return startedAt Timestamp at which the round started.
+ * @return updatedAt Timestamp at which the round was last updated.
+ * @return answeredInRound Round identifier in which the answer was computed.
+ */
+ function latestRoundData()
+ external
+ view
+ returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
+}
diff --git a/src/rules/interfaces/IAddressList.sol b/src/rules/interfaces/IAddressList.sol
index 36e4b791..768b42ad 100644
--- a/src/rules/interfaces/IAddressList.sol
+++ b/src/rules/interfaces/IAddressList.sol
@@ -9,16 +9,25 @@ import {IIdentityRegistryContains} from "./IIdentityRegistry.sol";
interface IAddressList is IIdentityRegistryContains {
/* ============ Events ============ */
/**
- * @notice Emitted when multiple addresses are added.
- * @param targetAddresses The array of added addresses.
+ * @notice Emitted when a batch add completes.
+ * @dev `targetAddresses` is the input array as submitted, NOT the set of addresses that changed
+ * state: a batch skips entries already present. `added` and `skipped` describe the effect, so a
+ * consumer can tell a batch of 100 new members from 100 no-ops without replaying the whole
+ * event history. The two always sum to `targetAddresses.length`.
+ * @param targetAddresses The array submitted by the caller.
+ * @param added Number of addresses newly inserted.
+ * @param skipped Number of addresses already present, left untouched.
*/
- event AddAddresses(address[] targetAddresses);
+ event AddAddresses(address[] targetAddresses, uint256 added, uint256 skipped);
/**
- * @notice Emitted when multiple addresses are removed.
- * @param targetAddresses The array of removed addresses.
+ * @notice Emitted when a batch remove completes.
+ * @dev See {AddAddresses}: `targetAddresses` is the input, `removed` and `skipped` are the effect.
+ * @param targetAddresses The array submitted by the caller.
+ * @param removed Number of addresses actually removed.
+ * @param skipped Number of addresses that were not present.
*/
- event RemoveAddresses(address[] targetAddresses);
+ event RemoveAddresses(address[] targetAddresses, uint256 removed, uint256 skipped);
/**
* @notice Emitted when a single address is added.
diff --git a/src/rules/interfaces/IBalanceOf.sol b/src/rules/interfaces/IBalanceOf.sol
new file mode 100644
index 00000000..4c4e65e2
--- /dev/null
+++ b/src/rules/interfaces/IBalanceOf.sol
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title IBalanceOf — single-account balance query.
+ * @dev Declared here rather than importing a full `IERC20` so the rule depends on exactly the one
+ * function it calls, matching {ITotalSupply}. `balanceOf` is the only token surface
+ * {RuleMaxBalanceBase} needs.
+ */
+interface IBalanceOf {
+ /**
+ * @notice Returns the token balance of `account`.
+ * @param account The address to query.
+ * @return The balance held by `account`.
+ */
+ function balanceOf(address account) external view returns (uint256);
+}
diff --git a/src/rules/interfaces/IDecimals.sol b/src/rules/interfaces/IDecimals.sol
new file mode 100644
index 00000000..e6cab7cc
--- /dev/null
+++ b/src/rules/interfaces/IDecimals.sol
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title IDecimals — token decimals query.
+ * @notice Optional ERC-20 metadata getter, used to cross-check a configured decimals value
+ * against the token's own on-chain metadata. Tokens are not required to implement it.
+ */
+interface IDecimals {
+ /**
+ * @notice Returns the number of decimals used by the token.
+ * @return The token decimals.
+ */
+ function decimals() external view returns (uint8);
+}
diff --git a/src/rules/interfaces/library/AddressListInterfaceId.sol b/src/rules/interfaces/library/AddressListInterfaceId.sol
index 24a263c5..eb370874 100644
--- a/src/rules/interfaces/library/AddressListInterfaceId.sol
+++ b/src/rules/interfaces/library/AddressListInterfaceId.sol
@@ -6,20 +6,10 @@ pragma solidity ^0.8.20;
* @title AddressListInterfaceId
* @dev ERC-165 interface ID for the full {IAddressList} hierarchy (XOR of all function selectors).
*
- * `type(IAddressList).interfaceId` CANNOT be used: it only XORs the selectors declared directly
+ * `type(IAddressList).interfaceId` CANNOT be used: it XORs only the selectors declared directly
* on `IAddressList` and omits `contains(address)`, inherited from `IIdentityRegistryContains`.
* This constant is computed from the flattened `IAddressListAllFunctions` interface instead.
*
- * Selectors XOR-ed:
- * addAddresses(address[]) 0x3628731c
- * removeAddresses(address[]) 0xa84eb999
- * addAddress(address) 0x38eada1c
- * removeAddress(address) 0x4ba79dfe
- * listedAddressCount() 0x2ea5461d
- * isAddressListed(address) 0xe3c88c6a
- * areAddressesListed(address[]) 0x20e8e17a
- * contains(address) 0x5dbe47e8 <- inherited
- *
* See src/mocks/IAddressListInterfaceIdHelper.sol; the value is asserted by
* test/InterfaceId/AddressListInterfaceId.t.sol.
*/
diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol b/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol
index 2b781b5f..7f17b201 100644
--- a/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol
+++ b/src/rules/operation/abstract/RuleConditionalTransferLightApprovalBase.sol
@@ -51,10 +51,11 @@ abstract contract RuleConditionalTransferLightApprovalBase is RuleConditionalTra
* @param to The recipient of the transfer to approve.
* @param value The amount of the transfer to approve.
*/
- function approveTransfer(address from, address to, uint256 value) public onlyTransferApprover {
+ function approveTransfer(address from, address to, uint256 value) public virtual onlyTransferApprover {
bytes32 transferHash = _transferHash(from, to, value);
- approvalCounts[transferHash] += 1;
- emit TransferApproved(from, to, value, approvalCounts[transferHash]);
+ uint256 newCount = approvalCounts[transferHash] + 1;
+ approvalCounts[transferHash] = newCount;
+ emit TransferApproved(from, to, value, newCount);
}
/**
@@ -63,7 +64,7 @@ abstract contract RuleConditionalTransferLightApprovalBase is RuleConditionalTra
* @param to The recipient of the transfer whose approval is cancelled.
* @param value The amount of the transfer whose approval is cancelled.
*/
- function cancelTransferApproval(address from, address to, uint256 value) public onlyTransferApprover {
+ function cancelTransferApproval(address from, address to, uint256 value) public virtual onlyTransferApprover {
bytes32 transferHash = _transferHash(from, to, value);
uint256 count = approvalCounts[transferHash];
require(count != 0, TransferApprovalNotFound());
@@ -142,13 +143,27 @@ abstract contract RuleConditionalTransferLightApprovalBase is RuleConditionalTra
/**
* @notice Computes the storage key identifying a (from, to, value) transfer.
+ * @dev The preimage is project-specific: **96 bytes, three words, each address LEFT-aligned and
+ * right-padded with 12 zero bytes** (`from || to || value`).
+ * WARNING: this is NEITHER `abi.encodePacked` (72 bytes, no padding) NOR `abi.encode` (96 bytes,
+ * addresses RIGHT-aligned). Reimplementing it off-chain as either yields a different hash, and
+ * because the result is a mapping key the mistake is **silent** -- the lookup returns 0, which is
+ * indistinguishable from "no approval exists". Either of these reproduces it:
+ * ```solidity
+ * keccak256(abi.encodePacked(from, bytes12(0), to, bytes12(0), value))
+ * keccak256(abi.encode(bytes32(bytes20(from)), bytes32(bytes20(to)), value))
+ * ```
+ * Pinned by `testDocumentedPreimageMatchesTheStorageKey`. Use {approvedCount} unless you need
+ * the storage slot directly.
* @param from The sender of the transfer.
* @param to The recipient of the transfer.
* @param value The amount of the transfer.
* @return hash The keccak256 hash uniquely identifying the transfer.
*/
function _transferHash(address from, address to, uint256 value) internal pure virtual returns (bytes32 hash) {
- // Linter suggestion (`asm-keccak256`): hash packed values in assembly to avoid abi.encodePacked overhead.
+ // Hand-rolled rather than `abi.encodePacked` on the linter's `asm-keccak256` advice: this is
+ // on the transfer write path, and the assembly is ~109 gas cheaper per call. Injectivity is
+ // verified in `CLAUDE_AUDIT.md` F-12; the exact layout is documented above.
assembly ("memory-safe") {
let ptr := mload(0x40)
mstore(ptr, shl(96, from))
diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol b/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol
index a2cd55f2..7aeeb492 100644
--- a/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol
+++ b/src/rules/operation/abstract/RuleConditionalTransferLightBase.sol
@@ -112,6 +112,7 @@ abstract contract RuleConditionalTransferLightBase is
*/
function approveAndTransferIfAllowed(address from, address to, uint256 value)
public
+ virtual
onlyTransferApprover
returns (bool)
{
@@ -132,6 +133,7 @@ abstract contract RuleConditionalTransferLightBase is
*/
function transferred(address from, address to, uint256 value)
public
+ virtual
override(IERC3643IComplianceContract)
onlyTransferExecutor
{
@@ -149,6 +151,7 @@ abstract contract RuleConditionalTransferLightBase is
uint256 value
)
public
+ virtual
override(IRuleEngine)
onlyTransferExecutor
{
@@ -174,34 +177,24 @@ abstract contract RuleConditionalTransferLightBase is
* {unbindRuleEngine} before rebinding.
* @param token The ERC-20 token to bind to this rule.
*/
- function bindToken(address token) public override onlyComplianceManager {
+ function bindToken(address token) public virtual override onlyComplianceManager {
require(getTokenBound() == address(0), RuleConditionalTransferLight_TokenAlreadyBound());
_bindToken(token);
}
/**
* @notice Authorizes a RuleEngine to call this rule's transfer execution hooks.
- * @dev Independent of {bindToken}: the engine is authorized to call `transferred`, but is never
- * treated as the ERC-20 token. Bind the token with {bindToken} and the engine here, and
- * {approveAndTransferIfAllowed} works under the RuleEngine topology.
- * Reverts if a RuleEngine is already bound; call {unbindRuleEngine} first to migrate.
- *
- * @dev WARNING: **Bind ONLY an engine that serves this one token.**
- * This rule's approvals are keyed `(from, to, value)` — they carry **no token dimension**.
- * A `RuleEngine` is multi-tenant by design (`_boundTokens` is a set), and it relays every
- * one of its tokens into the same `transferred(from, to, value)` hook, so the rule cannot
- * tell which token moved. If the bound engine serves several tokens, an approval recorded
- * for one of them is consumable by ANY of them:
- *
- * approveTransfer(alice, bob, 100) // intended for token A
- * // -> engine -> transferred(alice, bob, 100)
- * // the token-A approval is consumed
- *
- * This is inherent to the single-token rule and is why {RuleConditionalTransferLightMultiToken}
- * exists. Binding an engine does not change it — it only makes the topology usable, so the
- * constraint must be respected by the operator. If the engine is (or may become)
- * multi-tenant, do not use this rule.
+ * @dev Independent of {bindToken}: the engine may call `transferred` but is never treated as the
+ * ERC-20 token. Bind both and {approveAndTransferIfAllowed} works under the engine topology.
+ * Reverts if an engine is already bound; call {unbindRuleEngine} first to migrate.
*
+ * @dev WARNING: **bind ONLY an engine that serves this one token.** Approvals here are keyed
+ * `(from, to, value)` with **no token dimension**, while a `RuleEngine` is multi-tenant by
+ * design and relays every one of its tokens into the same hook. If the engine serves several
+ * tokens, an approval recorded for one is consumable by ANY of them -- approve 100 for token
+ * A, and a 100 transfer of token B consumes it. That is inherent to the single-token rule and
+ * is why {RuleConditionalTransferLightMultiToken} exists; binding an engine does not change
+ * it. If the engine is or may become multi-tenant, do not use this rule.
* @param ruleEngine_ The RuleEngine allowed to call `transferred`. It MUST serve only the token
* bound via {bindToken}.
*/
@@ -303,7 +296,7 @@ abstract contract RuleConditionalTransferLightBase is
* execution hooks. Both topologies are therefore supported without conflating the two
* roles of the binding — see {ruleEngine}.
*/
- function _authorizeTransferExecution() internal view override {
+ function _authorizeTransferExecution() internal view virtual override {
require(
isTransferExecutor(_msgSender()), RuleConditionalTransferLight_TransferExecutorUnauthorized(_msgSender())
);
diff --git a/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol
index 08c42e1e..127c79b1 100644
--- a/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol
+++ b/src/rules/operation/abstract/RuleConditionalTransferLightMultiTokenBase.sol
@@ -96,7 +96,11 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
* @param to The recipient of the transfer to approve.
* @param value The amount of the transfer to approve.
*/
- function approveTransfer(address token, address from, address to, uint256 value) public onlyTransferApprover {
+ function approveTransfer(address token, address from, address to, uint256 value)
+ public
+ virtual
+ onlyTransferApprover
+ {
_approveTransfer(token, from, to, value);
}
@@ -109,6 +113,7 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
*/
function cancelTransferApproval(address token, address from, address to, uint256 value)
public
+ virtual
onlyTransferApprover
{
_cancelTransferApproval(token, from, to, value);
@@ -125,6 +130,7 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
*/
function approveAndTransferIfAllowed(address token, address from, address to, uint256 value)
public
+ virtual
onlyTransferApprover
returns (bool)
{
@@ -146,6 +152,7 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
*/
function transferred(address from, address to, uint256 value)
public
+ virtual
override(IERC3643IComplianceContract)
onlyTransferExecutor
{
@@ -163,6 +170,7 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
uint256 value
)
public
+ virtual
override(IRuleEngine)
onlyTransferExecutor
{
@@ -177,7 +185,7 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
* - Deliberately does NOT require the token to be bound, unlike {approveTransfer}: the primary
* use is cleaning up approvals that survived an {unbindToken}, at which point the token is by
* definition no longer bound. It is also the only way to clear approvals stranded under a key
- * that can never be consumed (see `RESULT.md` finding F-4).
+ * that can never be consumed (see `CLAUDE_AUDIT.md` finding F-4).
* @param token The token whose approvals are cleared.
* @param from The sender of the transfer whose approvals are cleared.
* @param to The recipient of the transfer whose approvals are cleared.
@@ -338,8 +346,9 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
function _approveTransfer(address token, address from, address to, uint256 value) internal virtual {
require(isTokenBound(token), RuleConditionalTransferLightMultiToken_InvalidToken());
bytes32 transferHash = _transferHash(token, from, to, value);
- approvalCounts[transferHash] += 1;
- emit TransferApproved(token, from, to, value, approvalCounts[transferHash]);
+ uint256 newCount = approvalCounts[transferHash] + 1;
+ approvalCounts[transferHash] = newCount;
+ emit TransferApproved(token, from, to, value, newCount);
}
/**
@@ -432,6 +441,12 @@ abstract contract RuleConditionalTransferLightMultiTokenBase is
/**
* @notice Computes the storage key identifying a (token, from, to, value) transfer.
+ * @dev Same project-specific encoding as the single-token rule with `token` prepended: **128
+ * bytes, four words, each address LEFT-aligned and right-padded with 12 zero bytes.**
+ *
+ * WARNING: NEITHER `abi.encodePacked` NOR `abi.encode`. See
+ * {RuleConditionalTransferLightApprovalBase._transferHash} for why that matters and for the
+ * off-chain formulations that reproduce it. Use {approvedCount} unless you need the storage slot.
* @param token The token the transfer applies to.
* @param from The sender of the transfer.
* @param to The recipient of the transfer.
diff --git a/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol b/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol
new file mode 100644
index 00000000..07aa64ea
--- /dev/null
+++ b/src/rules/validation/abstract/RuleAddressSet/AddressSetBatchLib.sol
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
+
+/**
+ * @title AddressSetBatchLib
+ * @notice The batch add/remove loops shared by every address-list rule in this library.
+ * @dev Extracted because the same two loops were written three times and had already drifted: only
+ * the {RuleAddressSetInternal} copy was covered by a zero-address test (`CLAUDE_ANALYSIS.md` D-1).
+ * Only the loops live here; single-address `add` / `remove` / `contains` / `length` stay as one-line
+ * delegations to {EnumerableSet}, where a library would add indirection without removing duplication.
+ *
+ * @dev **The zero-address guard is a function-pointer parameter** so each rule keeps its own error
+ * (`RuleAddressSet_ZeroAddressNotAllowed` vs `RuleERC2980_ZeroAddressNotAllowed`), per the
+ * one-error-namespace-per-rule convention. Being a required parameter makes it MANDATORY: the call
+ * does not compile without one. Returning a "zero found" flag instead would make the guard optional
+ * in practice, and a caller that forgot it would list `address(0)` -- exactly what it prevents. The
+ * pointer resolves at compile time and the library is `internal`, so this is a jump, not a
+ * `DELEGATECALL`.
+ */
+library AddressSetBatchLib {
+ using EnumerableSet for EnumerableSet.AddressSet;
+
+ /**
+ * @notice Adds every address in `addressesToAdd` to `set`, skipping entries already present.
+ * @dev Duplicates are skipped and counted rather than rejected: an idempotent no-op that the
+ * caller's batch event still describes truthfully. `address(0)` is NOT skipped -- `guard` is
+ * invoked for every entry and is expected to revert on it, rejecting the whole batch. Silently
+ * dropping the sentinel would make the caller's `Add*` event, which echoes the input array,
+ * report a member that is not in the set.
+ * @param set The address set to modify.
+ * @param addressesToAdd The addresses to add.
+ * @param guard Per-entry validation supplied by the calling rule; reverts with that rule's own
+ * error. Invoked before the entry is inserted.
+ * @return added The number of addresses newly inserted.
+ * @return skipped The number of addresses already present.
+ */
+ function addBatch(
+ EnumerableSet.AddressSet storage set,
+ address[] calldata addressesToAdd,
+ function(address) internal pure guard
+ ) internal returns (uint256 added, uint256 skipped) {
+ for (uint256 i = 0; i < addressesToAdd.length; ++i) {
+ guard(addressesToAdd[i]);
+ if (set.add(addressesToAdd[i])) {
+ added += 1;
+ } else {
+ skipped += 1;
+ }
+ }
+ }
+
+ /**
+ * @notice Removes every address in `addressesToRemove` from `set`, skipping absent entries.
+ * @dev No guard: removal has no invalid input. Removing an address that is not present is an
+ * idempotent no-op, counted in `skipped`.
+ * @param set The address set to modify.
+ * @param addressesToRemove The addresses to remove.
+ * @return removed The number of addresses actually removed.
+ * @return skipped The number of addresses that were not present.
+ */
+ function removeBatch(EnumerableSet.AddressSet storage set, address[] calldata addressesToRemove)
+ internal
+ returns (uint256 removed, uint256 skipped)
+ {
+ for (uint256 i = 0; i < addressesToRemove.length; ++i) {
+ if (set.remove(addressesToRemove[i])) {
+ removed += 1;
+ } else {
+ skipped += 1;
+ }
+ }
+ }
+}
diff --git a/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol b/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol
index c55bdd8c..d152b103 100644
--- a/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol
+++ b/src/rules/validation/abstract/RuleAddressSet/RuleAddressSet.sol
@@ -4,6 +4,7 @@ pragma solidity ^0.8.20;
import {MetaTxModuleStandalone, ERC2771Context} from "../../../../modules/MetaTxModuleStandalone.sol";
import {RuleAddressSetInternal} from "./RuleAddressSetInternal.sol";
import {RuleAddressSetInvariantStorage} from "./invariantStorage/RuleAddressSetInvariantStorage.sol";
+import {RuleAddressSetRolesStorage} from "./invariantStorage/RuleAddressSetRolesStorage.sol";
/* ==== Interfaces === */
import {IIdentityRegistryContains} from "../../../interfaces/IIdentityRegistry.sol";
import {IAddressList} from "../../../interfaces/IAddressList.sol";
@@ -20,6 +21,7 @@ import {IAddressList} from "../../../interfaces/IAddressList.sol";
abstract contract RuleAddressSet is
MetaTxModuleStandalone,
RuleAddressSetInvariantStorage,
+ RuleAddressSetRolesStorage,
RuleAddressSetInternal,
IAddressList
{
@@ -54,13 +56,16 @@ abstract contract RuleAddressSet is
/**
* @notice Adds multiple addresses to the set.
* @dev
- * - Does not revert if an address is already listed.
+ * - Does not revert if an address is already listed; duplicates are skipped.
+ * - REVERTS on `address(0)`, rejecting the WHOLE batch. The mint/burn sentinel is never a list
+ * member, and skipping it would make the {AddAddresses} event -- which echoes the input array
+ * -- name it as one. Filter the input before submitting a large batch.
* - Accessible only by accounts with the `ADDRESS_LIST_ADD_ROLE`.
* @param targetAddresses Array of addresses to be added.
*/
- function addAddresses(address[] calldata targetAddresses) public onlyAddressListAdd {
- _addAddresses(targetAddresses);
- emit AddAddresses(targetAddresses);
+ function addAddresses(address[] calldata targetAddresses) public virtual onlyAddressListAdd {
+ (uint256 added, uint256 skipped) = _addAddresses(targetAddresses);
+ emit AddAddresses(targetAddresses, added, skipped);
}
/**
@@ -70,9 +75,9 @@ abstract contract RuleAddressSet is
* - Accessible only by accounts with the `ADDRESS_LIST_REMOVE_ROLE`.
* @param targetAddresses Array of addresses to remove.
*/
- function removeAddresses(address[] calldata targetAddresses) public onlyAddressListRemove {
- _removeAddresses(targetAddresses);
- emit RemoveAddresses(targetAddresses);
+ function removeAddresses(address[] calldata targetAddresses) public virtual onlyAddressListRemove {
+ (uint256 removed, uint256 skipped) = _removeAddresses(targetAddresses);
+ emit RemoveAddresses(targetAddresses, removed, skipped);
}
/**
@@ -82,10 +87,9 @@ abstract contract RuleAddressSet is
* - Accessible only by accounts with the `ADDRESS_LIST_ADD_ROLE`.
* @param targetAddress The address to be added.
*/
- function addAddress(address targetAddress) public onlyAddressListAdd {
+ function addAddress(address targetAddress) public virtual onlyAddressListAdd {
require(targetAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
- require(!_isAddressListed(targetAddress), RuleAddressSet_AddressAlreadyListed());
- _addAddress(targetAddress);
+ require(_addAddress(targetAddress), RuleAddressSet_AddressAlreadyListed());
emit AddAddress(targetAddress);
}
@@ -96,9 +100,8 @@ abstract contract RuleAddressSet is
* - Accessible only by accounts with the `ADDRESS_LIST_REMOVE_ROLE`.
* @param targetAddress The address to be removed.
*/
- function removeAddress(address targetAddress) public onlyAddressListRemove {
- require(_isAddressListed(targetAddress), RuleAddressSet_AddressNotFound());
- _removeAddress(targetAddress);
+ function removeAddress(address targetAddress) public virtual onlyAddressListRemove {
+ require(_removeAddress(targetAddress), RuleAddressSet_AddressNotFound());
emit RemoveAddress(targetAddress);
}
diff --git a/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol b/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol
index 39e0dd7e..3af9eee2 100644
--- a/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol
+++ b/src/rules/validation/abstract/RuleAddressSet/RuleAddressSetInternal.sol
@@ -3,6 +3,7 @@ pragma solidity ^0.8.20;
/* ==== OpenZeppelin === */
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
+import {AddressSetBatchLib} from "./AddressSetBatchLib.sol";
import {RuleAddressSetInvariantStorage} from "./invariantStorage/RuleAddressSetInvariantStorage.sol";
/**
@@ -15,6 +16,7 @@ import {RuleAddressSetInvariantStorage} from "./invariantStorage/RuleAddressSetI
*/
abstract contract RuleAddressSetInternal is RuleAddressSetInvariantStorage {
using EnumerableSet for EnumerableSet.AddressSet;
+ using AddressSetBatchLib for EnumerableSet.AddressSet;
/*//////////////////////////////////////////////////////////////
STATE VARIABLES
@@ -34,25 +36,33 @@ abstract contract RuleAddressSetInternal is RuleAddressSetInvariantStorage {
* @dev
* - Does not revert if an address is already listed.
* - Skips existing entries silently.
+ * - REVERTS on `address(0)`, rejecting the whole batch; see the inline comment below.
* @param addressesToAdd The array of addresses to add.
* @return added The number of newly added addresses.
* @return skipped The number of addresses that were already listed.
*/
- function _addAddresses(address[] calldata addressesToAdd) internal returns (uint256 added, uint256 skipped) {
- for (uint256 i = 0; i < addressesToAdd.length; ++i) {
- // The zero address is the mint/burn sentinel, never a participant. It is REJECTED
- // rather than skipped: the batch convention skips *duplicates* (an idempotent no-op that
- // the emitted event still describes truthfully), but silently dropping address(0) would
- // make `AddAddresses` report a member that is not in the set — re-polluting the very
- // off-chain view this guard exists to keep clean. Mint/burn is governed by
- // allowMint/allowBurn, never by list membership.
- require(addressesToAdd[i] != address(0), RuleAddressSet_ZeroAddressNotAllowed());
- if (_listedAddresses.add(addressesToAdd[i])) {
- added += 1;
- } else {
- skipped += 1;
- }
- }
+ function _addAddresses(address[] calldata addressesToAdd)
+ internal
+ virtual
+ returns (uint256 added, uint256 skipped)
+ {
+ return _listedAddresses.addBatch(addressesToAdd, _requireNotZeroAddress);
+ }
+
+ /**
+ * @notice Per-entry guard for {_addAddresses}; reverts on the zero address.
+ * @dev The zero address is the mint/burn sentinel, never a participant. It is REJECTED rather
+ * than skipped: the batch convention skips *duplicates* (an idempotent no-op that the emitted
+ * event still describes truthfully), but silently dropping address(0) would make `AddAddresses`
+ * report a member that is not in the set — re-polluting the very off-chain view this guard
+ * exists to keep clean. Mint/burn is governed by allowMint/allowBurn, never by list membership.
+ *
+ * Passed to {AddressSetBatchLib.addBatch} as a function pointer so the shared loop can reject
+ * the sentinel with THIS rule's error rather than a generic one.
+ * @param targetAddress The candidate address.
+ */
+ function _requireNotZeroAddress(address targetAddress) internal pure {
+ require(targetAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
}
/**
@@ -66,31 +76,32 @@ abstract contract RuleAddressSetInternal is RuleAddressSetInvariantStorage {
*/
function _removeAddresses(address[] calldata addressesToRemove)
internal
+ virtual
returns (uint256 removed, uint256 skipped)
{
- for (uint256 i = 0; i < addressesToRemove.length; ++i) {
- if (_listedAddresses.remove(addressesToRemove[i])) {
- removed += 1;
- } else {
- skipped += 1;
- }
- }
+ return _listedAddresses.removeBatch(addressesToRemove);
}
/**
* @notice Adds a single address to the set.
+ * @dev Forwards {EnumerableSet}'s "did this change anything" result so the caller can reject a
+ * duplicate without a second lookup: the membership test the caller would otherwise perform is
+ * the same one `add` already does internally (`CLAUDE_ANALYSIS.md` B-4).
* @param targetAddress The address to add.
+ * @return True when the address was not already listed.
*/
- function _addAddress(address targetAddress) internal virtual {
- _listedAddresses.add(targetAddress);
+ function _addAddress(address targetAddress) internal virtual returns (bool) {
+ return _listedAddresses.add(targetAddress);
}
/**
* @notice Removes a single address from the set.
+ * @dev Forwards {EnumerableSet}'s result; see {_addAddress}.
* @param targetAddress The address to remove.
+ * @return True when the address was listed and has been removed.
*/
- function _removeAddress(address targetAddress) internal virtual {
- _listedAddresses.remove(targetAddress);
+ function _removeAddress(address targetAddress) internal virtual returns (bool) {
+ return _listedAddresses.remove(targetAddress);
}
/**
diff --git a/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol b/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol
index 36c82338..cd0dba36 100644
--- a/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol
+++ b/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol
@@ -3,19 +3,11 @@
pragma solidity ^0.8.20;
/**
- * @title RuleAddressSetInvariantStorage — roles and errors for the address-set rule.
+ * @title RuleAddressSetInvariantStorage — errors shared by the address-set rules.
+ * @dev The roles gating the public API live in {RuleAddressSetRolesStorage}: a contract that
+ * inherits only {RuleAddressSetInternal} must not advertise roles it never enforces.
*/
abstract contract RuleAddressSetInvariantStorage {
- /* ============ Role ============ */
- /**
- * @notice Role allowed to remove addresses from the set.
- */
- bytes32 public constant ADDRESS_LIST_REMOVE_ROLE = keccak256("ADDRESS_LIST_REMOVE_ROLE");
- /**
- * @notice Role allowed to add addresses to the set.
- */
- bytes32 public constant ADDRESS_LIST_ADD_ROLE = keccak256("ADDRESS_LIST_ADD_ROLE");
-
/* ============ Custom errors ============ */
/**
* @notice Thrown when trying to add an address that is already listed.
diff --git a/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol b/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol
new file mode 100644
index 00000000..ce1c1962
--- /dev/null
+++ b/src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MPL-2.0
+
+pragma solidity ^0.8.20;
+
+/**
+ * @title RuleAddressSetRolesStorage — the roles gating the public address-set API.
+ * @dev Deliberately separate from {RuleAddressSetInvariantStorage}. These two roles authorise
+ * `addAddress` / `removeAddress`, which live on {RuleAddressSet} -- the *public* layer. A
+ * contract that inherits only {RuleAddressSetInternal} (the storage primitives) reuses the
+ * set machinery without exposing that API, and must not advertise roles it never checks:
+ * `IdentityRegistryWhitelist` gates registration on `IDENTITY_REGISTRAR_ROLE`, so publishing
+ * `ADDRESS_LIST_ADD_ROLE` there would invite an operator to grant a privilege that authorises
+ * nothing, with no on-chain signal that it had no effect.
+ *
+ * Keeping the roles here means only the layer that enforces them declares them.
+ */
+abstract contract RuleAddressSetRolesStorage {
+ /**
+ * @notice Role allowed to remove addresses from the set.
+ */
+ bytes32 public constant ADDRESS_LIST_REMOVE_ROLE = keccak256("ADDRESS_LIST_REMOVE_ROLE");
+ /**
+ * @notice Role allowed to add addresses to the set.
+ */
+ bytes32 public constant ADDRESS_LIST_ADD_ROLE = keccak256("ADDRESS_LIST_ADD_ROLE");
+}
diff --git a/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol b/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol
index 7677db35..1e9033b2 100644
--- a/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol
+++ b/src/rules/validation/abstract/RuleERC2980/RuleERC2980Internal.sol
@@ -3,6 +3,7 @@ pragma solidity ^0.8.20;
/* ==== OpenZeppelin === */
import {EnumerableSet} from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
+import {AddressSetBatchLib} from "../RuleAddressSet/AddressSetBatchLib.sol";
import {RuleERC2980InvariantStorage} from "./invariantStorage/RuleERC2980InvariantStorage.sol";
/**
@@ -16,6 +17,7 @@ import {RuleERC2980InvariantStorage} from "./invariantStorage/RuleERC2980Invaria
*/
abstract contract RuleERC2980Internal is RuleERC2980InvariantStorage {
using EnumerableSet for EnumerableSet.AddressSet;
+ using AddressSetBatchLib for EnumerableSet.AddressSet;
/*//////////////////////////////////////////////////////////////
STATE VARIABLES
@@ -37,24 +39,17 @@ abstract contract RuleERC2980Internal is RuleERC2980InvariantStorage {
/**
* @notice Adds multiple addresses to the whitelist, skipping any already present.
+ * @dev REVERTS on `address(0)`, rejecting the whole batch; see the inline comment below.
* @param addressesToAdd Addresses to add to the whitelist.
* @return added Number of addresses newly added.
* @return skipped Number of addresses that were already whitelisted.
*/
function _addWhitelistAddresses(address[] calldata addressesToAdd)
internal
+ virtual
returns (uint256 added, uint256 skipped)
{
- for (uint256 i = 0; i < addressesToAdd.length; ++i) {
- // The zero address is the mint/burn sentinel, never a participant. REJECTED rather than
- // skipped, so the emitted batch event can never report it as a list member.
- require(addressesToAdd[i] != address(0), RuleERC2980_ZeroAddressNotAllowed());
- if (_whitelist.add(addressesToAdd[i])) {
- added += 1;
- } else {
- skipped += 1;
- }
- }
+ return _whitelist.addBatch(addressesToAdd, _requireNotZeroAddress);
}
/**
@@ -65,31 +60,26 @@ abstract contract RuleERC2980Internal is RuleERC2980InvariantStorage {
*/
function _removeWhitelistAddresses(address[] calldata addressesToRemove)
internal
+ virtual
returns (uint256 removed, uint256 skipped)
{
- for (uint256 i = 0; i < addressesToRemove.length; ++i) {
- if (_whitelist.remove(addressesToRemove[i])) {
- removed += 1;
- } else {
- skipped += 1;
- }
- }
+ return _whitelist.removeBatch(addressesToRemove);
}
/**
* @notice Adds a single address to the whitelist.
* @param targetAddress Address to add to the whitelist.
*/
- function _addWhitelistAddress(address targetAddress) internal virtual {
- _whitelist.add(targetAddress);
+ function _addWhitelistAddress(address targetAddress) internal virtual returns (bool) {
+ return _whitelist.add(targetAddress);
}
/**
* @notice Removes a single address from the whitelist.
* @param targetAddress Address to remove from the whitelist.
*/
- function _removeWhitelistAddress(address targetAddress) internal virtual {
- _whitelist.remove(targetAddress);
+ function _removeWhitelistAddress(address targetAddress) internal virtual returns (bool) {
+ return _whitelist.remove(targetAddress);
}
/*//////////////////////////////////////////////////////////////
@@ -98,24 +88,17 @@ abstract contract RuleERC2980Internal is RuleERC2980InvariantStorage {
/**
* @notice Adds multiple addresses to the frozenlist, skipping any already present.
+ * @dev REVERTS on `address(0)`, rejecting the whole batch; see the inline comment below.
* @param addressesToAdd Addresses to add to the frozenlist.
* @return added Number of addresses newly added.
* @return skipped Number of addresses that were already frozen.
*/
function _addFrozenlistAddresses(address[] calldata addressesToAdd)
internal
+ virtual
returns (uint256 added, uint256 skipped)
{
- for (uint256 i = 0; i < addressesToAdd.length; ++i) {
- // The zero address is the mint/burn sentinel, never a participant. REJECTED rather than
- // skipped, so the emitted batch event can never report it as a list member.
- require(addressesToAdd[i] != address(0), RuleERC2980_ZeroAddressNotAllowed());
- if (_frozenlist.add(addressesToAdd[i])) {
- added += 1;
- } else {
- skipped += 1;
- }
- }
+ return _frozenlist.addBatch(addressesToAdd, _requireNotZeroAddress);
}
/**
@@ -126,31 +109,38 @@ abstract contract RuleERC2980Internal is RuleERC2980InvariantStorage {
*/
function _removeFrozenlistAddresses(address[] calldata addressesToRemove)
internal
+ virtual
returns (uint256 removed, uint256 skipped)
{
- for (uint256 i = 0; i < addressesToRemove.length; ++i) {
- if (_frozenlist.remove(addressesToRemove[i])) {
- removed += 1;
- } else {
- skipped += 1;
- }
- }
+ return _frozenlist.removeBatch(addressesToRemove);
}
/**
* @notice Adds a single address to the frozenlist.
* @param targetAddress Address to add to the frozenlist.
*/
- function _addFrozenlistAddress(address targetAddress) internal virtual {
- _frozenlist.add(targetAddress);
+ function _addFrozenlistAddress(address targetAddress) internal virtual returns (bool) {
+ return _frozenlist.add(targetAddress);
}
/**
* @notice Removes a single address from the frozenlist.
* @param targetAddress Address to remove from the frozenlist.
*/
- function _removeFrozenlistAddress(address targetAddress) internal virtual {
- _frozenlist.remove(targetAddress);
+ function _removeFrozenlistAddress(address targetAddress) internal virtual returns (bool) {
+ return _frozenlist.remove(targetAddress);
+ }
+
+ /**
+ * @notice Per-entry guard for both batch adders; reverts on the zero address.
+ * @dev The zero address is the mint/burn sentinel, never a participant. REJECTED rather than
+ * skipped, so the emitted batch event can never report it as a list member. Passed to
+ * {AddressSetBatchLib.addBatch} as a function pointer so the shared loop rejects the sentinel
+ * with THIS rule's error rather than a generic one.
+ * @param targetAddress The candidate address.
+ */
+ function _requireNotZeroAddress(address targetAddress) internal pure {
+ require(targetAddress != address(0), RuleERC2980_ZeroAddressNotAllowed());
}
/*//////////////////////////////////////////////////////////////
diff --git a/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol b/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol
index 4748eac5..06121362 100644
--- a/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol
+++ b/src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol
@@ -80,15 +80,21 @@ abstract contract RuleERC2980InvariantStorage is RuleSharedInvariantStorage {
/* ============ Events ============ */
/**
- * @notice Emitted when multiple addresses are added to the whitelist.
- * @param targetAddresses Addresses added to the whitelist.
+ * @notice Emitted when a batch whitelist add completes.
+ * @dev `targetAddresses` is the input as submitted, not the set that changed state; `added` and
+ * `skipped` describe the effect. See {IAddressList.AddAddresses}.
+ * @param targetAddresses The array submitted by the caller.
+ * @param added Number of addresses newly whitelisted.
+ * @param skipped Number of addresses already whitelisted.
*/
- event AddWhitelistAddresses(address[] targetAddresses);
+ event AddWhitelistAddresses(address[] targetAddresses, uint256 added, uint256 skipped);
/**
- * @notice Emitted when multiple addresses are removed from the whitelist.
- * @param targetAddresses Addresses removed from the whitelist.
+ * @notice Emitted when a batch whitelist remove completes.
+ * @param targetAddresses The array submitted by the caller.
+ * @param removed Number of addresses actually removed.
+ * @param skipped Number of addresses that were not whitelisted.
*/
- event RemoveWhitelistAddresses(address[] targetAddresses);
+ event RemoveWhitelistAddresses(address[] targetAddresses, uint256 removed, uint256 skipped);
/**
* @notice Emitted when a single address is added to the whitelist.
* @param targetAddress Address added to the whitelist.
@@ -101,15 +107,19 @@ abstract contract RuleERC2980InvariantStorage is RuleSharedInvariantStorage {
event RemoveWhitelistAddress(address indexed targetAddress);
/**
- * @notice Emitted when multiple addresses are added to the frozenlist.
- * @param targetAddresses Addresses added to the frozenlist.
+ * @notice Emitted when a batch frozenlist add completes.
+ * @param targetAddresses The array submitted by the caller.
+ * @param added Number of addresses newly frozen.
+ * @param skipped Number of addresses already frozen.
*/
- event AddFrozenlistAddresses(address[] targetAddresses);
+ event AddFrozenlistAddresses(address[] targetAddresses, uint256 added, uint256 skipped);
/**
- * @notice Emitted when multiple addresses are removed from the frozenlist.
- * @param targetAddresses Addresses removed from the frozenlist.
+ * @notice Emitted when a batch frozenlist remove completes.
+ * @param targetAddresses The array submitted by the caller.
+ * @param removed Number of addresses actually removed.
+ * @param skipped Number of addresses that were not frozen.
*/
- event RemoveFrozenlistAddresses(address[] targetAddresses);
+ event RemoveFrozenlistAddresses(address[] targetAddresses, uint256 removed, uint256 skipped);
/**
* @notice Emitted when a single address is added to the frozenlist.
* @param targetAddress Address added to the frozenlist.
diff --git a/src/rules/validation/abstract/base/RuleBlacklistBase.sol b/src/rules/validation/abstract/base/RuleBlacklistBase.sol
index 4bb111b2..aa7c5258 100644
--- a/src/rules/validation/abstract/base/RuleBlacklistBase.sol
+++ b/src/rules/validation/abstract/base/RuleBlacklistBase.sol
@@ -118,6 +118,7 @@ abstract contract RuleBlacklistBase is RuleAddressSet, RuleNFTAdapter, RuleBlack
)
internal
view
+ virtual
override
returns (uint8)
{
@@ -140,6 +141,7 @@ abstract contract RuleBlacklistBase is RuleAddressSet, RuleNFTAdapter, RuleBlack
function _detectTransferRestrictionFrom(address spender, address from, address to, uint256 value)
internal
view
+ virtual
override
returns (uint8)
{
diff --git a/src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol b/src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol
new file mode 100644
index 00000000..c5114f6b
--- /dev/null
+++ b/src/rules/validation/abstract/base/RuleChainlinkPoRBase.sol
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC1404, IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {AggregatorV3Interface} from "../../../interfaces/AggregatorV3Interface.sol";
+import {IERC3643IComplianceContract} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+import {RuleTransferValidation} from "../core/RuleTransferValidation.sol";
+import {ChainlinkPoRFeedManager} from "../core/ChainlinkPoRFeedManager.sol";
+
+/**
+ * @title RuleChainlinkPoRBase
+ * @notice Caps minting at the reserves reported by a Chainlink Proof of Reserve feed. The limit
+ * equals the reported reserves exactly -- no margin or buffer.
+ * @dev Only mints are gated: transfers do not change total supply and burns only reduce it.
+ *
+ * @dev The rule half: constructor, ERC-1404 / ERC-3643 surface, and the mapping from a backed supply
+ * to a restriction code. The feed itself -- which feed, which token, staleness, scaling and the
+ * revert-free read -- lives in {ChainlinkPoRFeedManager}.
+ *
+ * @dev The read path must never revert, and every failure is fail-closed (the mint is blocked): an
+ * unreadable or over-precision feed yields {CODE_RESERVES_FEED_UNAVAILABLE}, a negative or
+ * incomplete answer {CODE_RESERVES_ANSWER_INVALID}, an old one {CODE_RESERVES_FEED_STALE}, and an
+ * unreadable `totalSupply()` {CODE_TOTAL_SUPPLY_UNAVAILABLE}. The token is trusted to report an
+ * accurate supply, but not to stay callable -- that is guarded.
+ */
+abstract contract RuleChainlinkPoRBase is RuleTransferValidation, ChainlinkPoRFeedManager {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Initializes the rule with the protected token and the reserve feed.
+ * @dev Configuration is delegated to {ChainlinkPoRFeedManager}'s internals, which are
+ * constructor-agnostic; an upgradeable variant would call the same three from an initializer.
+ * @param tokenContract_ Address of the token whose `totalSupply` is checked; must not be the zero address.
+ * @param tokenDecimals_ Decimals of that token; must be at most {MAX_TOKEN_DECIMALS} and, when
+ * the token exposes `decimals()`, must match it. `0` is valid and common for CMTAT equity tokens.
+ * @param reservesFeed_ Proof of Reserve data feed; must be a contract exposing `AggregatorV3Interface`.
+ * @param maxStalenessSeconds_ Initial staleness threshold in seconds; 0 disables the check.
+ */
+ constructor(
+ address tokenContract_,
+ uint8 tokenDecimals_,
+ AggregatorV3Interface reservesFeed_,
+ uint256 maxStalenessSeconds_
+ ) {
+ _setReservesFeed(reservesFeed_);
+ _setTokenMetadata(tokenContract_, tokenDecimals_);
+ _setMaxStalenessSeconds(maxStalenessSeconds_);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns whether this rule can produce the given restriction code.
+ * @param restrictionCode Restriction code to test.
+ * @return True if `restrictionCode` is one of this rule's codes.
+ */
+ function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override returns (bool) {
+ return restrictionCode == CODE_RESERVES_EXCEEDED || restrictionCode == CODE_RESERVES_FEED_STALE
+ || restrictionCode == CODE_RESERVES_ANSWER_INVALID || restrictionCode == CODE_RESERVES_FEED_UNAVAILABLE
+ || restrictionCode == CODE_TOTAL_SUPPLY_UNAVAILABLE;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc IERC3643IComplianceContract
+ */
+ function transferred(address from, address to, uint256 value) public view override(IERC3643IComplianceContract) {
+ _transferred(from, to, value);
+ }
+
+ /**
+ * @inheritdoc IRuleEngine
+ */
+ function transferred(address spender, address from, address to, uint256 value) public view override(IRuleEngine) {
+ _transferredFrom(spender, from, to, value);
+ }
+
+ /**
+ * @inheritdoc IERC1404
+ */
+ function messageForTransferRestriction(uint8 restrictionCode)
+ public
+ pure
+ override(IERC1404)
+ returns (string memory)
+ {
+ if (restrictionCode == CODE_RESERVES_EXCEEDED) {
+ return TEXT_RESERVES_EXCEEDED;
+ } else if (restrictionCode == CODE_RESERVES_FEED_STALE) {
+ return TEXT_RESERVES_FEED_STALE;
+ } else if (restrictionCode == CODE_RESERVES_ANSWER_INVALID) {
+ return TEXT_RESERVES_ANSWER_INVALID;
+ } else if (restrictionCode == CODE_RESERVES_FEED_UNAVAILABLE) {
+ return TEXT_RESERVES_FEED_UNAVAILABLE;
+ } else if (restrictionCode == CODE_TOTAL_SUPPLY_UNAVAILABLE) {
+ return TEXT_TOTAL_SUPPLY_UNAVAILABLE;
+ }
+ return TEXT_CODE_NOT_FOUND;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc RuleTransferValidation
+ */
+ function _detectTransferRestriction(
+ address from,
+ address,
+ /* to */
+ uint256 value
+ )
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ // Only mints change the total supply upwards; transfers and burns are never gated.
+ if (from != address(0)) {
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+ (uint8 restrictionCode, uint256 backedSupply) = _maxBackedSupply();
+ if (restrictionCode != uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK)) {
+ return restrictionCode;
+ }
+ (bool supplyAvailable, uint256 currentSupply) = _currentSupply();
+ if (!supplyAvailable) {
+ return CODE_TOTAL_SUPPLY_UNAVAILABLE;
+ }
+ // Overflow-safe: `currentSupply + value` could exceed uint256 and this is a
+ // MUST-NOT-revert ERC-1404/ERC-3643 view, so compare against the remaining headroom.
+ if (currentSupply > backedSupply || value > backedSupply - currentSupply) {
+ return CODE_RESERVES_EXCEEDED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /**
+ * @inheritdoc RuleTransferValidation
+ */
+ function _detectTransferRestrictionFrom(address, address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ return _detectTransferRestriction(from, to, value);
+ }
+
+ /**
+ * @notice Enforces the reserve backing for a direct transfer, reverting on violation.
+ * @param from Sender address; the zero address denotes a mint whose backing is checked.
+ * @param to Recipient address.
+ * @param value Transfer amount.
+ */
+ function _transferred(address from, address to, uint256 value) internal view virtual {
+ uint8 code = _detectTransferRestriction(from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleChainlinkPoR_InvalidTransfer(address(this), from, to, value, code)
+ );
+ }
+
+ /**
+ * @notice Enforces the reserve backing for a `transferFrom`, reverting on violation.
+ * @param spender Approved spender initiating the transfer; the minter on the mint path.
+ * @param from Sender address; the zero address denotes a mint whose backing is checked.
+ * @param to Recipient address.
+ * @param value Transfer amount.
+ */
+ function _transferredFrom(address spender, address from, address to, uint256 value) internal view virtual {
+ uint8 code = _detectTransferRestrictionFrom(spender, from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleChainlinkPoR_InvalidTransferFrom(address(this), spender, from, to, value, code)
+ );
+ }
+}
diff --git a/src/rules/validation/abstract/base/RuleERC2980Base.sol b/src/rules/validation/abstract/base/RuleERC2980Base.sol
index e8e1ebb0..7d65b35b 100644
--- a/src/rules/validation/abstract/base/RuleERC2980Base.sol
+++ b/src/rules/validation/abstract/base/RuleERC2980Base.sol
@@ -103,12 +103,13 @@ abstract contract RuleERC2980Base is
/**
* @notice Adds multiple addresses to the whitelist.
- * @dev Does not revert if an address is already listed.
+ * @dev Does not revert if an address is already listed; duplicates are skipped. REVERTS on
+ * `address(0)`, rejecting the whole batch -- see {addWhitelistAddress}.
* @param targetAddresses Addresses to add to the whitelist.
*/
- function addWhitelistAddresses(address[] calldata targetAddresses) public onlyWhitelistAdd {
- _addWhitelistAddresses(targetAddresses);
- emit AddWhitelistAddresses(targetAddresses);
+ function addWhitelistAddresses(address[] calldata targetAddresses) public virtual onlyWhitelistAdd {
+ (uint256 added, uint256 skipped) = _addWhitelistAddresses(targetAddresses);
+ emit AddWhitelistAddresses(targetAddresses, added, skipped);
}
/**
@@ -116,9 +117,9 @@ abstract contract RuleERC2980Base is
* @dev Does not revert if an address is not listed.
* @param targetAddresses Addresses to remove from the whitelist.
*/
- function removeWhitelistAddresses(address[] calldata targetAddresses) public onlyWhitelistRemove {
- _removeWhitelistAddresses(targetAddresses);
- emit RemoveWhitelistAddresses(targetAddresses);
+ function removeWhitelistAddresses(address[] calldata targetAddresses) public virtual onlyWhitelistRemove {
+ (uint256 removed, uint256 skipped) = _removeWhitelistAddresses(targetAddresses);
+ emit RemoveWhitelistAddresses(targetAddresses, removed, skipped);
}
/**
@@ -130,10 +131,9 @@ abstract contract RuleERC2980Base is
* convention of reverting on invalid single-item operations.
* @param targetAddress Address to add to the whitelist.
*/
- function addWhitelistAddress(address targetAddress) public onlyWhitelistAdd {
+ function addWhitelistAddress(address targetAddress) public virtual onlyWhitelistAdd {
require(targetAddress != address(0), RuleERC2980_ZeroAddressNotAllowed());
- require(!_isWhitelisted(targetAddress), RuleERC2980_AddressAlreadyWhitelisted());
- _addWhitelistAddress(targetAddress);
+ require(_addWhitelistAddress(targetAddress), RuleERC2980_AddressAlreadyWhitelisted());
emit AddWhitelistAddress(targetAddress);
}
@@ -146,9 +146,8 @@ abstract contract RuleERC2980Base is
* convention of reverting on invalid single-item operations.
* @param targetAddress Address to remove from the whitelist.
*/
- function removeWhitelistAddress(address targetAddress) public onlyWhitelistRemove {
- require(_isWhitelisted(targetAddress), RuleERC2980_AddressNotWhitelisted());
- _removeWhitelistAddress(targetAddress);
+ function removeWhitelistAddress(address targetAddress) public virtual onlyWhitelistRemove {
+ require(_removeWhitelistAddress(targetAddress), RuleERC2980_AddressNotWhitelisted());
emit RemoveWhitelistAddress(targetAddress);
}
@@ -158,12 +157,13 @@ abstract contract RuleERC2980Base is
/**
* @notice Adds multiple addresses to the frozenlist.
- * @dev Does not revert if an address is already listed.
+ * @dev Does not revert if an address is already listed; duplicates are skipped. REVERTS on
+ * `address(0)`, rejecting the whole batch -- see {addFrozenlistAddress}.
* @param targetAddresses Addresses to add to the frozenlist.
*/
- function addFrozenlistAddresses(address[] calldata targetAddresses) public onlyFrozenlistAdd {
- _addFrozenlistAddresses(targetAddresses);
- emit AddFrozenlistAddresses(targetAddresses);
+ function addFrozenlistAddresses(address[] calldata targetAddresses) public virtual onlyFrozenlistAdd {
+ (uint256 added, uint256 skipped) = _addFrozenlistAddresses(targetAddresses);
+ emit AddFrozenlistAddresses(targetAddresses, added, skipped);
}
/**
@@ -171,9 +171,9 @@ abstract contract RuleERC2980Base is
* @dev Does not revert if an address is not listed.
* @param targetAddresses Addresses to remove from the frozenlist.
*/
- function removeFrozenlistAddresses(address[] calldata targetAddresses) public onlyFrozenlistRemove {
- _removeFrozenlistAddresses(targetAddresses);
- emit RemoveFrozenlistAddresses(targetAddresses);
+ function removeFrozenlistAddresses(address[] calldata targetAddresses) public virtual onlyFrozenlistRemove {
+ (uint256 removed, uint256 skipped) = _removeFrozenlistAddresses(targetAddresses);
+ emit RemoveFrozenlistAddresses(targetAddresses, removed, skipped);
}
/**
@@ -185,10 +185,9 @@ abstract contract RuleERC2980Base is
* convention of reverting on invalid single-item operations.
* @param targetAddress Address to add to the frozenlist.
*/
- function addFrozenlistAddress(address targetAddress) public onlyFrozenlistAdd {
+ function addFrozenlistAddress(address targetAddress) public virtual onlyFrozenlistAdd {
require(targetAddress != address(0), RuleERC2980_ZeroAddressNotAllowed());
- require(!_isFrozen(targetAddress), RuleERC2980_AddressAlreadyFrozen());
- _addFrozenlistAddress(targetAddress);
+ require(_addFrozenlistAddress(targetAddress), RuleERC2980_AddressAlreadyFrozen());
emit AddFrozenlistAddress(targetAddress);
}
@@ -201,9 +200,8 @@ abstract contract RuleERC2980Base is
* convention of reverting on invalid single-item operations.
* @param targetAddress Address to remove from the frozenlist.
*/
- function removeFrozenlistAddress(address targetAddress) public onlyFrozenlistRemove {
- require(_isFrozen(targetAddress), RuleERC2980_AddressNotFrozen());
- _removeFrozenlistAddress(targetAddress);
+ function removeFrozenlistAddress(address targetAddress) public virtual onlyFrozenlistRemove {
+ require(_removeFrozenlistAddress(targetAddress), RuleERC2980_AddressNotFrozen());
emit RemoveFrozenlistAddress(targetAddress);
}
diff --git a/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol b/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol
index b07ad452..b6340a20 100644
--- a/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol
+++ b/src/rules/validation/abstract/base/RuleIdentityRegistryBase.sol
@@ -11,22 +11,14 @@ import {IIdentityRegistryVerified} from "../../../interfaces/IIdentityRegistry.s
/**
* @title RuleIdentityRegistryBase
* @notice Checks the ERC-3643 Identity Registry for transfer participants when configured.
- * @dev **ERC-3643 conformant by default.** The specification mandates that ONLY THE RECEIVER be
- * identity-verified:
+ * @dev **ERC-3643 conformant by default: only the RECEIVER is verified.** The spec states the
+ * receiver must be whitelisted and verified, that `transferFrom` works the same way, that
+ * `mint` and `forcedTransfer` require only the receiver, and that `burn` bypasses eligibility.
*
- * - "The receiver MUST be whitelisted on the Identity Registry and verified" (§ Transfer)
- * - "`transferFrom` works the same way" (§ Transfer)
- * - "`mint` and `forcedTransfer` only require the receiver to be whitelisted
- * and verified on the Identity Registry" (§ Transfer)
- * - "The `burn` function bypasses all checks on eligibility" (§ Transfer)
- *
- * The sender, the spender and the minter are NOT required to be verified. Checking the sender
- * in particular would TRAP DE-LISTED HOLDERS: ERC-3643 screens only the receiver precisely so
- * that an investor whose identity lapses (expired claim, revoked identity) can still exit their
- * position by sending to a verified counterparty.
- *
- * Stricter screening remains available, but as an EXPLICIT OPT-IN ({checkSender},
- * {checkSpender}) rather than an undocumented default.
+ * The sender, spender and minter are NOT required to be verified. Checking the sender would
+ * TRAP DE-LISTED HOLDERS: the spec screens only the receiver precisely so an investor whose
+ * identity lapses can still exit to a verified counterparty. Stricter screening is available as
+ * an explicit opt-in ({checkSender}, {checkSpender}), not an undocumented default.
*/
abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegistryInvariantStorage {
/**
@@ -61,8 +53,13 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
* @param checkSpender_ When true, also verify the spender on `transferFrom` (STRICTER than ERC-3643).
*/
constructor(address identityRegistry_, bool checkSender_, bool checkSpender_) {
+ // Every value actually assigned here is announced, so the deployed configuration can be
+ // reconstructed from events alone. The registry is only assigned when non-zero -- a zero
+ // argument leaves the default untouched, so there is nothing to report, matching
+ // {RuleSanctionsListBase}'s constructor.
if (identityRegistry_ != address(0)) {
identityRegistry = IIdentityRegistryVerified(identityRegistry_);
+ emit IdentityRegistryUpdated(identityRegistry_);
}
checkSender = checkSender_;
checkSpender = checkSpender_;
@@ -101,7 +98,7 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
* @notice Sets the identity registry consulted during transfer checks.
* @param newRegistry New identity registry address; must not be the zero address.
*/
- function setIdentityRegistry(address newRegistry) public onlyIdentityRegistryManager {
+ function setIdentityRegistry(address newRegistry) public virtual onlyIdentityRegistryManager {
require(newRegistry != address(0), RuleIdentityRegistry_RegistryAddressZeroNotAllowed());
identityRegistry = IIdentityRegistryVerified(newRegistry);
emit IdentityRegistryUpdated(newRegistry);
@@ -131,7 +128,7 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
/**
* @notice Clears the identity registry, disabling identity checks (all transfers pass this rule).
*/
- function clearIdentityRegistry() public onlyIdentityRegistryManager {
+ function clearIdentityRegistry() public virtual onlyIdentityRegistryManager {
identityRegistry = IIdentityRegistryVerified(address(0));
emit IdentityRegistryUpdated(address(0));
}
@@ -191,10 +188,14 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
)
internal
view
+ virtual
override
returns (uint8)
{
- if (address(identityRegistry) == address(0)) {
+ // Read the registry address once. Safe to cache across the calls below: this function is
+ // `view`, so those are STATICCALLs and cannot write `identityRegistry`.
+ IIdentityRegistryVerified registry = identityRegistry;
+ if (address(registry) == address(0)) {
return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
}
// ERC-3643: "The `burn` function bypasses all checks on eligibility."
@@ -203,13 +204,13 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
}
// OPT-IN, stricter than ERC-3643. Mints carry no sender, so they are exempt.
- if (checkSender && from != address(0) && !identityRegistry.isVerified(from)) {
+ if (checkSender && from != address(0) && !registry.isVerified(from)) {
return CODE_ADDRESS_FROM_NOT_VERIFIED;
}
// MANDATED by ERC-3643: the receiver must be verified. This is the only required check,
// and it applies identically to `transfer`, `transferFrom` and `mint`.
- if (!identityRegistry.isVerified(to)) {
+ if (!registry.isVerified(to)) {
return CODE_ADDRESS_TO_NOT_VERIFIED;
}
return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
@@ -226,10 +227,12 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
function _detectTransferRestrictionFrom(address spender, address from, address to, uint256 value)
internal
view
+ virtual
override
returns (uint8)
{
- if (address(identityRegistry) == address(0)) {
+ IIdentityRegistryVerified registry = identityRegistry;
+ if (address(registry) == address(0)) {
return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
}
// ERC-3643: burn bypasses all eligibility checks.
@@ -238,13 +241,13 @@ abstract contract RuleIdentityRegistryBase is RuleNFTAdapter, RuleIdentityRegist
}
// OPT-IN, stricter than ERC-3643 ("`transferFrom` works the same way" — receiver only).
- // Mint (from == 0) and burn (to == 0) are exempt: the minter/burner acts on its own
- // authority, not as a delegated ERC-20 spender. This is what makes an unverified MINTER
- // able to mint to a verified recipient, exactly as the specification requires.
- if (
- checkSpender && spender != address(0) && from != address(0) && to != address(0)
- && !identityRegistry.isVerified(spender)
- ) {
+ // Mint (from == 0) is exempt: the minter acts on its own authority, not as a delegated
+ // ERC-20 spender. This is what makes an unverified MINTER able to mint to a verified
+ // recipient, exactly as the specification requires.
+ // Burn (to == 0) is exempt too, but by the early return above -- do NOT re-test `to` here.
+ // The condition would be dead, and re-stating it reads as though burn were handled at this
+ // point rather than six lines earlier.
+ if (checkSpender && spender != address(0) && from != address(0) && !registry.isVerified(spender)) {
return CODE_ADDRESS_SPENDER_NOT_VERIFIED;
}
return _detectTransferRestriction(from, to, value);
diff --git a/src/rules/validation/abstract/base/RuleMaxBalanceBase.sol b/src/rules/validation/abstract/base/RuleMaxBalanceBase.sol
new file mode 100644
index 00000000..346f5912
--- /dev/null
+++ b/src/rules/validation/abstract/base/RuleMaxBalanceBase.sol
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IERC1404, IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {IERC3643IComplianceContract} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+import {RuleTransferValidation} from "../core/RuleTransferValidation.sol";
+import {BalanceCapManager} from "../core/BalanceCapManager.sol";
+
+/**
+ * @title RuleMaxBalanceBase
+ * @notice Caps how many tokens a single address may hold, with an operator-managed exemption list.
+ * @dev The rule half: constructor, ERC-1404 / ERC-3643 surface, and the mapping from a breached cap
+ * to a restriction code; the cap itself lives in {BalanceCapManager}. Screens the **receiver** --
+ * rejected when `balanceOf(to) + value > maxBalance`, mints included. Burns and the sender are not.
+ *
+ * WARNING: **the cap counts tokens per address, so splitting a position across wallets defeats it.**
+ * Pair it with a rule tying addresses to identities (`RuleWhitelist`, `RuleReceiverWhitelist`,
+ * `RuleIdentityRegistry`) *and* admit one address per investor.
+ *
+ * @dev **Assumes the token calls this BEFORE moving the value**, so `balanceOf(to)` still excludes
+ * `value`. CMTAT does; a token notifying afterwards would halve the effective cap. Pinned by
+ * `testMintExactlyToTheCapProvesPreUpdateAccounting`.
+ *
+ * @dev `maxBalance = 0` forbids holding entirely; it does not disable the rule. The read path must
+ * never revert: an unreadable balance yields {CODE_BALANCE_UNAVAILABLE}
+ * (fail-closed). Burns and exempt receivers resolve before any balance is read.
+ */
+abstract contract RuleMaxBalanceBase is RuleTransferValidation, BalanceCapManager {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Initializes the rule with the observed token and the per-holder cap.
+ * @dev Routes through {BalanceCapManager}'s internals, which are constructor-agnostic, so the
+ * initial configuration is announced by {MaxBalanceTokenUpdated} and {MaxBalanceUpdated} exactly
+ * like every later change. An upgradeable variant would call the same two from an initializer.
+ * @param balanceToken_ Token whose `balanceOf` is checked; must be a contract.
+ * @param maxBalance_ Maximum balance per non-exempt address. `0` forbids holding entirely.
+ */
+ constructor(address balanceToken_, uint256 maxBalance_) {
+ _setBalanceToken(balanceToken_);
+ _setMaxBalance(maxBalance_);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns whether this rule can produce the given restriction code.
+ * @param restrictionCode Restriction code to test.
+ * @return True if `restrictionCode` is one of this rule's codes.
+ */
+ function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override returns (bool) {
+ return restrictionCode == CODE_MAX_BALANCE_EXCEEDED || restrictionCode == CODE_BALANCE_UNAVAILABLE;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns the balance `to` may still receive before reaching the cap.
+ * @dev Mirrors what {_detectTransferRestriction} computes, so an integrator can size a transfer
+ * without simulating it. Never reverts. This is the ERC-1404-flavoured wrapper over
+ * {BalanceCapManager._remainingCapacity}: the manager answers in booleans, the rule maps that to
+ * a restriction code.
+ * @param to The prospective receiver.
+ * @return restrictionCode `0` when the headroom is meaningful, otherwise the code a transfer
+ * would return.
+ * @return headroom Remaining capacity in token units. `type(uint256).max` for an exempt address
+ * or the burn sentinel; meaningless when `restrictionCode` is non-zero.
+ */
+ function remainingCapacity(address to) public view virtual returns (uint8 restrictionCode, uint256 headroom) {
+ (bool balanceAvailable, uint256 headroom_) = _remainingCapacity(to);
+ if (!balanceAvailable) {
+ return (CODE_BALANCE_UNAVAILABLE, 0);
+ }
+ return (uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK), headroom_);
+ }
+
+ /**
+ * @inheritdoc IERC3643IComplianceContract
+ */
+ function transferred(address from, address to, uint256 value) public view override(IERC3643IComplianceContract) {
+ _transferred(from, to, value);
+ }
+
+ /**
+ * @inheritdoc IRuleEngine
+ */
+ function transferred(address spender, address from, address to, uint256 value) public view override(IRuleEngine) {
+ _transferredFrom(spender, from, to, value);
+ }
+
+ /**
+ * @inheritdoc IERC1404
+ */
+ function messageForTransferRestriction(uint8 restrictionCode)
+ public
+ pure
+ override(IERC1404)
+ returns (string memory)
+ {
+ if (restrictionCode == CODE_MAX_BALANCE_EXCEEDED) {
+ return TEXT_MAX_BALANCE_EXCEEDED;
+ } else if (restrictionCode == CODE_BALANCE_UNAVAILABLE) {
+ return TEXT_BALANCE_UNAVAILABLE;
+ }
+ return TEXT_CODE_NOT_FOUND;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc RuleTransferValidation
+ */
+ function _detectTransferRestriction(
+ address,
+ /* from */
+ address to,
+ uint256 value
+ )
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ (bool balanceAvailable, bool exceeded) = _capExceeded(to, value);
+ if (!balanceAvailable) {
+ return CODE_BALANCE_UNAVAILABLE;
+ }
+ if (exceeded) {
+ return CODE_MAX_BALANCE_EXCEEDED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /**
+ * @inheritdoc RuleTransferValidation
+ * @dev The spender is irrelevant: the cap constrains who ends up holding the tokens, not who
+ * moved them.
+ */
+ function _detectTransferRestrictionFrom(address, address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ return _detectTransferRestriction(from, to, value);
+ }
+
+ /**
+ * @notice Enforces the cap for a direct transfer, reverting on violation.
+ * @param from Sender address.
+ * @param to Recipient address whose resulting balance is checked.
+ * @param value Transfer amount.
+ */
+ function _transferred(address from, address to, uint256 value) internal view virtual {
+ uint8 code = _detectTransferRestriction(from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleMaxBalance_InvalidTransfer(address(this), from, to, value, code)
+ );
+ }
+
+ /**
+ * @notice Enforces the cap for a `transferFrom`, reverting on violation.
+ * @param spender Approved spender initiating the transfer; the minter on the mint path.
+ * @param from Sender address.
+ * @param to Recipient address whose resulting balance is checked.
+ * @param value Transfer amount.
+ */
+ function _transferredFrom(address spender, address from, address to, uint256 value) internal view virtual {
+ uint8 code = _detectTransferRestrictionFrom(spender, from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleMaxBalance_InvalidTransferFrom(address(this), spender, from, to, value, code)
+ );
+ }
+}
diff --git a/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol b/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol
index 12297aa8..473653aa 100644
--- a/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol
+++ b/src/rules/validation/abstract/base/RuleMaxTotalSupplyBase.sol
@@ -1,40 +1,33 @@
// SPDX-License-Identifier: MPL-2.0
pragma solidity ^0.8.20;
-import {RuleMaxTotalSupplyInvariantStorage} from "../invariant/RuleMaxTotalSupplyInvariantStorage.sol";
import {IERC1404, IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
-import {ITotalSupply} from "../../../interfaces/ITotalSupply.sol";
import {IERC3643IComplianceContract} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol";
import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
import {RuleTransferValidation} from "../core/RuleTransferValidation.sol";
+import {TotalSupplyCapManager} from "../core/TotalSupplyCapManager.sol";
/**
* @title RuleMaxTotalSupplyBase
* @notice Restricts minting so that total supply never exceeds a maximum value.
*/
-abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, RuleMaxTotalSupplyInvariantStorage {
- /**
- * @dev tokenContract is trusted to return a correct totalSupply.
- */
- ITotalSupply public tokenContract;
- /**
- * @notice Maximum total supply; minting that would exceed this value is rejected.
- */
- uint256 public maxTotalSupply;
-
+abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, TotalSupplyCapManager {
/*//////////////////////////////////////////////////////////////
CONSTRUCTOR
//////////////////////////////////////////////////////////////*/
/**
* @notice Initializes the rule with the token to observe and the supply cap.
+ * @dev Routes through the same internal setters the public API uses, so the initial
+ * configuration is announced by {TokenContractUpdated} and {MaxTotalSupplyUpdated} exactly like
+ * every later change. A cap that is set once at deployment and never touched would otherwise
+ * have no on-chain event trail at all.
* @param tokenContract_ Address of the token whose `totalSupply` is checked; must not be the zero address.
* @param maxTotalSupply_ Maximum total supply allowed.
*/
constructor(address tokenContract_, uint256 maxTotalSupply_) {
- require(tokenContract_ != address(0), RuleMaxTotalSupply_TokenAddressZeroNotAllowed());
- tokenContract = ITotalSupply(tokenContract_);
- maxTotalSupply = maxTotalSupply_;
+ _setTokenContract(tokenContract_);
+ _setMaxTotalSupply(maxTotalSupply_);
}
/*//////////////////////////////////////////////////////////////
@@ -47,32 +40,13 @@ abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, RuleMaxTotal
* @return True if `restrictionCode` is the max-total-supply-exceeded code.
*/
function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override returns (bool) {
- return restrictionCode == CODE_MAX_TOTAL_SUPPLY_EXCEEDED;
+ return restrictionCode == CODE_MAX_TOTAL_SUPPLY_EXCEEDED || restrictionCode == CODE_SUPPLY_ORACLE_UNAVAILABLE;
}
/*//////////////////////////////////////////////////////////////
PUBLIC FUNCTIONS
//////////////////////////////////////////////////////////////*/
- /**
- * @notice Updates the maximum total supply.
- * @param newMaxTotalSupply New maximum total supply value.
- */
- function setMaxTotalSupply(uint256 newMaxTotalSupply) public onlyMaxTotalSupplyManager {
- maxTotalSupply = newMaxTotalSupply;
- emit MaxTotalSupplyUpdated(newMaxTotalSupply);
- }
-
- /**
- * @notice Updates the token contract whose total supply is checked.
- * @param newTokenContract New token contract address; must not be the zero address.
- */
- function setTokenContract(address newTokenContract) public onlyMaxTotalSupplyManager {
- require(newTokenContract != address(0), RuleMaxTotalSupply_TokenAddressZeroNotAllowed());
- tokenContract = ITotalSupply(newTokenContract);
- emit TokenContractUpdated(newTokenContract);
- }
-
/**
* @inheritdoc IERC3643IComplianceContract
*/
@@ -98,24 +72,12 @@ abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, RuleMaxTotal
{
if (restrictionCode == CODE_MAX_TOTAL_SUPPLY_EXCEEDED) {
return TEXT_MAX_TOTAL_SUPPLY_EXCEEDED;
+ } else if (restrictionCode == CODE_SUPPLY_ORACLE_UNAVAILABLE) {
+ return TEXT_SUPPLY_ORACLE_UNAVAILABLE;
}
return TEXT_CODE_NOT_FOUND;
}
- /*//////////////////////////////////////////////////////////////
- ACCESS CONTROL
- //////////////////////////////////////////////////////////////*/
-
- modifier onlyMaxTotalSupplyManager() {
- _authorizeMaxTotalSupplyManager();
- _;
- }
-
- /**
- * @notice Authorization hook invoked before updating the max total supply or token contract.
- */
- function _authorizeMaxTotalSupplyManager() internal view virtual;
-
/*//////////////////////////////////////////////////////////////
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
@@ -131,14 +93,16 @@ abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, RuleMaxTotal
)
internal
view
+ virtual
override
returns (uint8)
{
if (from == address(0)) {
- uint256 currentSupply = tokenContract.totalSupply();
- // Overflow-safe: `currentSupply + value` could exceed uint256 and this is a
- // MUST-NOT-revert ERC-1404/ERC-3643 view, so compare against the remaining headroom.
- if (currentSupply > maxTotalSupply || value > maxTotalSupply - currentSupply) {
+ (bool supplyAvailable, bool exceeded) = _capExceeded(value);
+ if (!supplyAvailable) {
+ return CODE_SUPPLY_ORACLE_UNAVAILABLE;
+ }
+ if (exceeded) {
return CODE_MAX_TOTAL_SUPPLY_EXCEEDED;
}
}
@@ -151,6 +115,7 @@ abstract contract RuleMaxTotalSupplyBase is RuleTransferValidation, RuleMaxTotal
function _detectTransferRestrictionFrom(address, address from, address to, uint256 value)
internal
view
+ virtual
override
returns (uint8)
{
diff --git a/src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol b/src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol
new file mode 100644
index 00000000..ce7cc3bf
--- /dev/null
+++ b/src/rules/validation/abstract/base/RuleReceiverWhitelistBase.sol
@@ -0,0 +1,165 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleAddressSet} from "../RuleAddressSet/RuleAddressSet.sol";
+import {RuleNFTAdapter} from "../core/RuleNFTAdapter.sol";
+import {RuleTransferValidation} from "../core/RuleTransferValidation.sol";
+import {RuleReceiverWhitelistInvariantStorage} from "../invariant/RuleReceiverWhitelistInvariantStorage.sol";
+import {AddressListInterfaceId} from "../../../interfaces/library/AddressListInterfaceId.sol";
+import {IERC1404, IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {IERC3643IComplianceContract} from "CMTAT/interfaces/tokenization/IERC3643Partial.sol";
+import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol";
+
+/**
+ * @title RuleReceiverWhitelistBase
+ * @notice A whitelist that screens **only the receiver**, reproducing ERC-3643's eligibility rule.
+ *
+ * @dev ERC-3643 mandates one identity check -- the receiver -- and states that `transferFrom` works
+ * the same way, `mint` requires only the receiver, and `burn` bypasses eligibility. Implemented
+ * literally: `to` is screened on transfer, `transferFrom` and mint; the spender and sender never
+ * are; burn is always allowed.
+ *
+ * @dev **Do not add a sender check.** It would trap de-listed holders, whose position would be
+ * stranded. The spec screens only the receiver precisely so a lapsed investor can still exit. Use
+ * {RuleWhitelist} if screening both parties is the policy you want.
+ *
+ * @dev Burn is exempt rather than checked because `address(0)` can never be listed, so without the
+ * exemption every burn would be rejected. That matches the spec, it is not a convenience.
+ *
+ * @dev There is no `allowMint` flag, unlike {RuleWhitelist}: ERC-3643 gates minting on receiver
+ * eligibility alone. Compose with `RuleMaxTotalSupply` or `RuleChainlinkPoR` to cap issuance.
+ */
+abstract contract RuleReceiverWhitelistBase is RuleAddressSet, RuleNFTAdapter, RuleReceiverWhitelistInvariantStorage {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the receiver-whitelist rule base.
+ * @param forwarderIrrevocable Trusted ERC-2771 forwarder address for meta-transactions.
+ */
+ constructor(address forwarderIrrevocable) RuleAddressSet(forwarderIrrevocable) {}
+
+ /*//////////////////////////////////////////////////////////////
+ EXTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns whether this rule can emit the given restriction code.
+ * @param restrictionCode The restriction code to check.
+ * @return True if the code is produced by this rule.
+ */
+ function canReturnTransferRestrictionCode(uint8 restrictionCode) external pure override returns (bool) {
+ return restrictionCode == CODE_ADDRESS_RECEIVER_NOT_WHITELISTED;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @inheritdoc IERC3643IComplianceContract
+ */
+ function transferred(address from, address to, uint256 value) public view override(IERC3643IComplianceContract) {
+ _transferred(from, to, value);
+ }
+
+ /**
+ * @inheritdoc IRuleEngine
+ */
+ function transferred(address spender, address from, address to, uint256 value) public view override(IRuleEngine) {
+ _transferredFrom(spender, from, to, value);
+ }
+
+ /**
+ * @inheritdoc IERC1404
+ */
+ function messageForTransferRestriction(uint8 restrictionCode)
+ public
+ pure
+ override(IERC1404)
+ returns (string memory)
+ {
+ if (restrictionCode == CODE_ADDRESS_RECEIVER_NOT_WHITELISTED) {
+ return TEXT_ADDRESS_RECEIVER_NOT_WHITELISTED;
+ }
+ return TEXT_CODE_NOT_FOUND;
+ }
+
+ /**
+ * @inheritdoc RuleTransferValidation
+ */
+ function supportsInterface(bytes4 interfaceId) public view virtual override(RuleTransferValidation) returns (bool) {
+ // Advertise IAddressList: this rule manages an address set and is callable through
+ // the IAddressList interface.
+ return interfaceId == AddressListInterfaceId.IADDRESS_LIST_INTERFACE_ID
+ || RuleTransferValidation.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Detects whether a transfer is blocked because the receiver is not whitelisted.
+ * @dev The sender is deliberately ignored; see the contract-level notes.
+ * @param to The recipient address; `address(0)` denotes a burn and is exempt.
+ * @return The restriction code, or TRANSFER_OK when allowed.
+ */
+ function _detectTransferRestriction(address, address to, uint256) internal view virtual override returns (uint8) {
+ // Burn (to == address(0)) bypasses eligibility per ERC-3643. It must be exempted
+ // explicitly: address(0) can never be listed, so it would otherwise always be rejected.
+ if (to != address(0) && !_isAddressListed(to)) {
+ return CODE_ADDRESS_RECEIVER_NOT_WHITELISTED;
+ }
+ return uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK);
+ }
+
+ /**
+ * @notice Detects whether a delegated transfer is blocked because the receiver is not whitelisted.
+ * @dev ERC-3643: `transferFrom` "works the same way" as `transfer`, so the spender is not
+ * screened and this delegates to {_detectTransferRestriction}.
+ * @param from The sender address.
+ * @param to The recipient address.
+ * @param value The amount transferred.
+ * @return The restriction code, or TRANSFER_OK when allowed.
+ */
+ function _detectTransferRestrictionFrom(address, address from, address to, uint256 value)
+ internal
+ view
+ virtual
+ override
+ returns (uint8)
+ {
+ return _detectTransferRestriction(from, to, value);
+ }
+
+ /**
+ * @notice Reverts if a direct transfer is blocked because the receiver is not whitelisted.
+ * @param from The sender address.
+ * @param to The recipient address.
+ * @param value The amount transferred.
+ */
+ function _transferred(address from, address to, uint256 value) internal view virtual override {
+ uint8 code = _detectTransferRestriction(from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleReceiverWhitelist_InvalidTransfer(address(this), from, to, value, code)
+ );
+ }
+
+ /**
+ * @notice Reverts if a delegated transfer is blocked because the receiver is not whitelisted.
+ * @param spender The delegated spender address; recorded in the error only, never screened.
+ * @param from The sender address.
+ * @param to The recipient address.
+ * @param value The amount transferred.
+ */
+ function _transferredFrom(address spender, address from, address to, uint256 value) internal view virtual override {
+ uint8 code = _detectTransferRestrictionFrom(spender, from, to, value);
+ require(
+ code == uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK),
+ RuleReceiverWhitelist_InvalidTransferFrom(address(this), spender, from, to, value, code)
+ );
+ }
+}
diff --git a/src/rules/validation/abstract/base/RuleSanctionsListBase.sol b/src/rules/validation/abstract/base/RuleSanctionsListBase.sol
index fa90c34c..0cf074d9 100644
--- a/src/rules/validation/abstract/base/RuleSanctionsListBase.sol
+++ b/src/rules/validation/abstract/base/RuleSanctionsListBase.sol
@@ -134,8 +134,14 @@ abstract contract RuleSanctionsListBase is MetaTxModuleStandalone, RuleNFTAdapte
/**
* @notice Detects whether a direct transfer is restricted by the sanctions oracle.
- * @param from The sender address.
- * @param to The recipient address.
+ * @dev The zero address is the ERC-20 mint/burn sentinel, not a participant, so it is never sent
+ * to the oracle: on a mint `from` is skipped, on a burn `to` is skipped. Asking a
+ * third-party contract whether `address(0)` is sanctioned would delegate this rule's
+ * mint/burn behaviour to that contract's handling of a degenerate input -- an oracle
+ * answering `true` would block ALL issuance and ALL redemption, trapping holders. Every
+ * other rule in this library screens only the real participants for the same reason.
+ * @param from The sender address; the zero address denotes a mint and is not screened.
+ * @param to The recipient address; the zero address denotes a burn and is not screened.
* @return The restriction code, or TRANSFER_OK when no party is sanctioned.
*/
function _detectTransferRestriction(
@@ -145,13 +151,17 @@ abstract contract RuleSanctionsListBase is MetaTxModuleStandalone, RuleNFTAdapte
)
internal
view
+ virtual
override
returns (uint8)
{
- if (address(sanctionsList) != address(0)) {
- if (sanctionsList.isSanctioned(from)) {
+ // Read the oracle address once. Safe to cache across the calls below: this function is
+ // `view`, so those are STATICCALLs and cannot write `sanctionsList`.
+ ISanctionsList oracle = sanctionsList;
+ if (address(oracle) != address(0)) {
+ if (from != address(0) && oracle.isSanctioned(from)) {
return CODE_ADDRESS_FROM_IS_SANCTIONED;
- } else if (sanctionsList.isSanctioned(to)) {
+ } else if (to != address(0) && oracle.isSanctioned(to)) {
return CODE_ADDRESS_TO_IS_SANCTIONED;
}
}
@@ -173,13 +183,15 @@ abstract contract RuleSanctionsListBase is MetaTxModuleStandalone, RuleNFTAdapte
override
returns (uint8)
{
- if (address(sanctionsList) != address(0)) {
- if (sanctionsList.isSanctioned(spender)) {
- return CODE_ADDRESS_SPENDER_IS_SANCTIONED;
- }
- return _detectTransferRestriction(from, to, value);
+ ISanctionsList oracle = sanctionsList;
+ // The oracle guard scopes ONLY the spender check; the delegation below is unconditional, as
+ // in every sibling rule. Nesting the delegation inside the guard -- as this function used to
+ // -- silently drops any check in {_detectTransferRestriction} that does not depend on the
+ // oracle, including one added by a subclass overriding that hook.
+ if (address(oracle) != address(0) && oracle.isSanctioned(spender)) {
+ return CODE_ADDRESS_SPENDER_IS_SANCTIONED;
}
- return uint8(REJECTED_CODE_BASE.TRANSFER_OK);
+ return _detectTransferRestriction(from, to, value);
}
/**
diff --git a/src/rules/validation/abstract/base/RuleWhitelistBase.sol b/src/rules/validation/abstract/base/RuleWhitelistBase.sol
index bb776661..caa7c355 100644
--- a/src/rules/validation/abstract/base/RuleWhitelistBase.sol
+++ b/src/rules/validation/abstract/base/RuleWhitelistBase.sol
@@ -32,7 +32,7 @@ abstract contract RuleWhitelistBase is RuleAddressSet, RuleWhitelistShared, IIde
constructor(address forwarderIrrevocable, bool checkSpender_, bool allowMintBurn)
RuleAddressSet(forwarderIrrevocable)
{
- checkSpender = checkSpender_;
+ _setCheckSpender(checkSpender_);
_setAllowMintBurn(allowMintBurn, allowMintBurn);
}
@@ -40,16 +40,6 @@ abstract contract RuleWhitelistBase is RuleAddressSet, RuleWhitelistShared, IIde
PUBLIC FUNCTIONS
//////////////////////////////////////////////////////////////*/
- /**
- * @notice Enables or disables spender verification on delegated transfers.
- * @dev Restricted to the check-spender manager; emits {CheckSpenderUpdated}.
- * @param value The new state of the `checkSpender` flag.
- */
- function setCheckSpender(bool value) public virtual onlyCheckSpenderManager {
- _setCheckSpender(value);
- emit CheckSpenderUpdated(value);
- }
-
/**
* @inheritdoc IIdentityRegistryVerified
*/
@@ -77,29 +67,10 @@ abstract contract RuleWhitelistBase is RuleAddressSet, RuleWhitelistShared, IIde
ACCESS CONTROL
//////////////////////////////////////////////////////////////*/
- modifier onlyCheckSpenderManager() {
- _authorizeCheckSpenderManager();
- _;
- }
-
/*//////////////////////////////////////////////////////////////
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
- /**
- * @notice Internal helper to update the `checkSpender` flag.
- * @param value New flag value.
- */
- function _setCheckSpender(bool value) internal virtual {
- checkSpender = value;
- }
-
- /**
- * @notice Authorizes the caller as check-spender manager; reverts otherwise.
- * @dev Implemented by concrete subclasses with the desired access-control policy.
- */
- function _authorizeCheckSpenderManager() internal view virtual;
-
/**
* @notice Detects whether a direct transfer is restricted by the whitelist.
* @param from The sender address.
diff --git a/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol b/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
index 6c95e3df..66896b09 100644
--- a/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
+++ b/src/rules/validation/abstract/base/RuleWhitelistWrapperBase.sol
@@ -37,36 +37,14 @@ abstract contract RuleWhitelistWrapperBase is
constructor(address forwarderIrrevocable, bool checkSpender_, bool allowMintBurn)
MetaTxModuleStandalone(forwarderIrrevocable)
{
- checkSpender = checkSpender_;
+ _setCheckSpender(checkSpender_);
_setAllowMintBurn(allowMintBurn, allowMintBurn);
}
- /*//////////////////////////////////////////////////////////////
- ACCESS CONTROL
- //////////////////////////////////////////////////////////////*/
-
- modifier onlyCheckSpenderManager() {
- _authorizeCheckSpenderManager();
- _;
- }
-
/*//////////////////////////////////////////////////////////////
PUBLIC FUNCTIONS
//////////////////////////////////////////////////////////////*/
- /**
- * @notice Sets whether the rule should enforce spender-based checks.
- * @dev
- * - Restricted to holders of the manager role.
- * - Updates the internal `checkSpender` flag.
- * - Emits a {CheckSpenderUpdated} event.
- * @param value The new state of the `checkSpender` flag.
- */
- function setCheckSpender(bool value) public virtual onlyCheckSpenderManager {
- _setCheckSpender(value);
- emit CheckSpenderUpdated(value);
- }
-
/**
* @inheritdoc RuleTransferValidation
*/
@@ -76,37 +54,20 @@ abstract contract RuleWhitelistWrapperBase is
/**
* @notice Returns true if the address is listed in at least one child whitelist rule.
- * @dev Delegates to the same child-rule scan used by transfer restriction checks.
+ * @dev Delegates to {_isListedInAnyChild}, the same single-address resolution the mint and burn
+ * branches of {_detectTransferRestriction} use, so the ERC-3643 eligibility view and the
+ * transfer check can never disagree about an address.
* @param targetAddress The address to check across all child whitelist rules.
* @return True if the address is listed in at least one child rule.
*/
function isVerified(address targetAddress) public view virtual override(IIdentityRegistryVerified) returns (bool) {
- address[] memory targets = new address[](1);
- targets[0] = targetAddress;
- bool[] memory result = _detectTransferRestrictionForTargets(targets);
- return result[0];
+ return _isListedInAnyChild(targetAddress);
}
/*//////////////////////////////////////////////////////////////
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
- /**
- * @notice Internal helper to update the `checkSpender` flag.
- * @param value New flag value.
- */
- function _setCheckSpender(bool value) internal virtual {
- checkSpender = value;
- }
-
- /**
- * @notice Authorizes the caller as check-spender manager; reverts otherwise.
- * @dev Implemented by concrete subclasses with the desired access-control policy.
- * `view` by convention: an access-control hook checks and reverts, it never mutates state.
- * Declaring it `view` makes that a compiler-enforced invariant rather than a convention.
- */
- function _authorizeCheckSpenderManager() internal view virtual;
-
/**
* @notice Go through all the whitelist rules to know if a restriction exists on the transfer
* @param from the origin address
@@ -264,26 +225,25 @@ abstract contract RuleWhitelistWrapperBase is
returns (bool[] memory)
{
uint256 rulesLength = rulesCount();
- bool[] memory result = new bool[](targetAddress.length);
+ uint256 targetsLength = targetAddress.length;
+ bool[] memory result = new bool[](targetsLength);
+ // Number of targets not yet found in any child. Decremented the first time a target is
+ // resolved, so the early exit below is an O(1) test rather than a full rescan of `result`
+ // on every child rule. The observable result is identical.
+ uint256 unresolved = targetsLength;
for (uint256 i = 0; i < rulesLength; ++i) {
// Call the whitelist rules
// Gas cost grows with the number of rules. Keep the wrapper list bounded.
bool[] memory isListed = IAddressList(rule(i)).areAddressesListed(targetAddress);
- for (uint256 j = 0; j < targetAddress.length; ++j) {
- if (isListed[j]) {
+ for (uint256 j = 0; j < targetsLength; ++j) {
+ if (isListed[j] && !result[j]) {
result[j] = true;
+ --unresolved;
}
}
// Break early if all listed
- bool allListed = true;
- for (uint256 k = 0; k < result.length; ++k) {
- if (!result[k]) {
- allListed = false;
- break;
- }
- }
- if (allListed) {
+ if (unresolved == 0) {
break;
}
}
diff --git a/src/rules/validation/abstract/core/BalanceCapManager.sol b/src/rules/validation/abstract/core/BalanceCapManager.sol
new file mode 100644
index 00000000..592d12f6
--- /dev/null
+++ b/src/rules/validation/abstract/core/BalanceCapManager.sol
@@ -0,0 +1,267 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleMaxBalanceInvariantStorage} from "../invariant/RuleMaxBalanceInvariantStorage.sol";
+import {IBalanceOf} from "../../../interfaces/IBalanceOf.sol";
+import {RuleAddressSetInternal} from "../RuleAddressSet/RuleAddressSetInternal.sol";
+
+/**
+ * @title BalanceCapManager
+ * @notice Per-address holding cap: the observed token, the cap, the exemption list, and how much a
+ * given address may still receive.
+ *
+ * @dev Declares **no constructor**, so an upgradeable variant can set the same state from an
+ * initializer. {_capExceeded} and {_remainingCapacity} answer in booleans and token units, leaving
+ * the restriction codes to the rule.
+ *
+ * @dev `maxBalance = 0` forbids holding entirely; it does not disable the cap.
+ *
+ * @dev {_balanceOf} must never revert, because the rule calls it from MUST-NOT-revert views, so it is
+ * `try/catch`-wrapped. That is only safe because the setter requires the token to have code and
+ * EIP-6780 makes it permanent: a `try` to a codeless address reverts *uncatchably*. Assumes a
+ * Cancun-or-later chain.
+ *
+ * @dev The exemption list reuses {RuleAddressSetInternal}, so the set storage, the zero-address guard
+ * and the batch semantics are shared code rather than a second implementation.
+ */
+abstract contract BalanceCapManager is RuleAddressSetInternal, RuleMaxBalanceInvariantStorage {
+ /**
+ * @notice The token whose balances are observed.
+ * @dev Trusted to report an accurate balance; not trusted to stay callable.
+ */
+ IBalanceOf public balanceToken;
+ /**
+ * @notice Maximum number of tokens a single non-exempt address may hold.
+ */
+ uint256 public maxBalance;
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ modifier onlyMaxBalanceManager() {
+ _authorizeMaxBalanceManager();
+ _;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Updates the maximum balance allowed per non-exempt address.
+ * @dev Lowering the cap does **not** claw back balances that already exceed it. Existing holders
+ * keep their tokens and may still send them away; they simply cannot receive more until they are
+ * back under the cap.
+ * @param newMaxBalance The new cap. `0` forbids holding entirely; it does not disable the cap.
+ */
+ function setMaxBalance(uint256 newMaxBalance) public virtual onlyMaxBalanceManager {
+ _setMaxBalance(newMaxBalance);
+ }
+
+ /**
+ * @notice Updates the token whose balances are observed.
+ * @param newBalanceToken The new token contract; must be a contract exposing `balanceOf`.
+ */
+ function setBalanceToken(address newBalanceToken) public virtual onlyMaxBalanceManager {
+ _setBalanceToken(newBalanceToken);
+ }
+
+ /**
+ * @notice Exempts an address from the cap.
+ * @dev Reverts if the address is already exempt, matching the single-item convention used
+ * elsewhere in the library. `address(0)` is rejected: it is the mint/burn sentinel, never a
+ * holder.
+ * @param targetAddress The address to exempt.
+ */
+ function addExemptAddress(address targetAddress) public virtual onlyMaxBalanceManager {
+ _addExemptAddress(targetAddress);
+ }
+
+ /**
+ * @notice Removes an address's exemption.
+ * @dev Reverts if the address is not exempt. The address keeps whatever it already holds; it
+ * simply cannot receive more once over the cap.
+ * @param targetAddress The address to bring back under the cap.
+ */
+ function removeExemptAddress(address targetAddress) public virtual onlyMaxBalanceManager {
+ _removeExemptAddress(targetAddress);
+ }
+
+ /**
+ * @notice Exempts several addresses in one call.
+ * @dev Duplicates are skipped and counted rather than reverting; `address(0)` rejects the whole
+ * batch. Both follow the library-wide batch convention.
+ * @param targetAddresses The addresses to exempt.
+ */
+ function addExemptAddresses(address[] calldata targetAddresses) public virtual onlyMaxBalanceManager {
+ (uint256 added, uint256 skipped) = _addAddresses(targetAddresses);
+ emit ExemptAddressesAdded(targetAddresses, added, skipped);
+ }
+
+ /**
+ * @notice Removes the exemption from several addresses in one call.
+ * @dev Addresses that are not exempt are skipped and counted rather than reverting.
+ * @param targetAddresses The addresses to bring back under the cap.
+ */
+ function removeExemptAddresses(address[] calldata targetAddresses) public virtual onlyMaxBalanceManager {
+ (uint256 removed, uint256 skipped) = _removeAddresses(targetAddresses);
+ emit ExemptAddressesRemoved(targetAddresses, removed, skipped);
+ }
+
+ /**
+ * @notice Returns whether an address is exempt from the cap.
+ * @param targetAddress The address to test.
+ * @return True when the address may hold any amount.
+ */
+ function isExemptAddress(address targetAddress) public view virtual returns (bool) {
+ return _isAddressListed(targetAddress);
+ }
+
+ /**
+ * @notice Returns how many addresses are exempt.
+ * @return The number of exempt addresses.
+ */
+ function exemptAddressCount() public view virtual returns (uint256) {
+ return _listedAddressCount();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Exempts an address: guards, writes and announces, in one place.
+ * @dev Owns the guards as well as the event, so every write path gets both. A subclass that
+ * wanted to pre-exempt a treasury address from its constructor can call this instead of
+ * restating the two `require`s, which is what the scalar setters already do via
+ * {_setMaxBalance} and {_setBalanceToken}.
+ *
+ * `_addAddress` does not guard the sentinel; the caller must, exactly as the whitelist
+ * rules and `IdentityRegistryWhitelist` do. The batch path is guarded separately, by the
+ * function pointer `_addAddresses` passes to `AddressSetBatchLib`. Invariant I-12.
+ * @param targetAddress The address to exempt.
+ */
+ function _addExemptAddress(address targetAddress) internal virtual {
+ require(targetAddress != address(0), RuleAddressSet_ZeroAddressNotAllowed());
+ require(_addAddress(targetAddress), RuleAddressSet_AddressAlreadyListed());
+ emit ExemptAddressAdded(targetAddress);
+ }
+
+ /**
+ * @notice Removes an exemption: guards, writes and announces, in one place.
+ * @param targetAddress The address to bring back under the cap.
+ */
+ function _removeExemptAddress(address targetAddress) internal virtual {
+ require(_removeAddress(targetAddress), RuleAddressSet_AddressNotFound());
+ emit ExemptAddressRemoved(targetAddress);
+ }
+
+ /**
+ * @notice Stores the cap and emits {MaxBalanceUpdated}.
+ * @param newMaxBalance The new cap.
+ */
+ function _setMaxBalance(uint256 newMaxBalance) internal virtual {
+ maxBalance = newMaxBalance;
+ emit MaxBalanceUpdated(newMaxBalance);
+ }
+
+ /**
+ * @notice Stores the observed token and emits {MaxBalanceTokenUpdated}.
+ * @dev Probes `balanceOf` at configuration time so a token that cannot serve the check fails
+ * loudly at setup instead of silently blocking every transfer.
+ * @param newBalanceToken The new token contract.
+ */
+ function _setBalanceToken(address newBalanceToken) internal virtual {
+ require(newBalanceToken != address(0), RuleMaxBalance_TokenAddressZeroNotAllowed());
+ // Explicit, rather than relying on the uncatchable extcodesize revert the probe below happens
+ // to produce for a codeless address: that is compiler behaviour, not a check.
+ require(newBalanceToken.code.length != 0, RuleMaxBalance_TokenIsNotAContract(newBalanceToken));
+ try IBalanceOf(newBalanceToken).balanceOf(address(this)) returns (uint256) {
+ // callable
+ }
+ catch {
+ revert RuleMaxBalance_TokenBalanceUnavailable(newBalanceToken);
+ }
+ balanceToken = IBalanceOf(newBalanceToken);
+ emit MaxBalanceTokenUpdated(newBalanceToken);
+ }
+
+ /**
+ * @notice Authorization hook invoked before any configuration or exemption change.
+ * @dev Implemented by concrete subclasses with the desired access-control policy.
+ */
+ function _authorizeMaxBalanceManager() internal view virtual;
+
+ /**
+ * @notice Returns the balance `to` may still receive before reaching the cap.
+ * @dev Never reverts. Kept `internal` and code-free deliberately: the rule wraps it in a public
+ * `remainingCapacity` that reports an ERC-1404 restriction code, which is a concern this
+ * contract does not carry.
+ * @param to The prospective receiver.
+ * @return balanceAvailable False when the balance could not be read; `headroom` is then
+ * meaningless and the caller should treat the query as failed.
+ * @return headroom Remaining capacity in token units. `type(uint256).max` for an exempt address
+ * or the burn sentinel.
+ */
+ function _remainingCapacity(address to) internal view virtual returns (bool balanceAvailable, uint256 headroom) {
+ if (to == address(0) || _isAddressListed(to)) {
+ return (true, type(uint256).max);
+ }
+ (bool available, uint256 balance) = _balanceOf(to);
+ if (!available) {
+ return (false, 0);
+ }
+ uint256 cap = maxBalance;
+ return (true, balance >= cap ? 0 : cap - balance);
+ }
+
+ /**
+ * @notice Reads an address's balance without ever reverting.
+ * @dev Wrapped in `try/catch` so the rule's read path stays revert-free if the token breaks after
+ * configuration -- a proxy upgraded to something that reverts, or a pausable implementation that
+ * reverts while paused.
+ * @param account The address to query.
+ * @return available True when the balance could be read.
+ * @return balance The balance; meaningless when `available` is false.
+ */
+ function _balanceOf(address account) internal view virtual returns (bool available, uint256 balance) {
+ try balanceToken.balanceOf(account) returns (uint256 balance_) {
+ return (true, balance_);
+ } catch {
+ return (false, 0);
+ }
+ }
+
+ /**
+ * @notice Reports whether `to` receiving `value` would breach its cap, without ever reverting.
+ * @dev Answers in booleans rather than restriction codes, so the caller owns the ERC-1404
+ * mapping. Burns and exempt receivers are resolved before any balance is read, so they keep
+ * working while the token is unreadable. Overflow-safe: `balance + value` could exceed uint256 on
+ * a MUST-NOT-revert path, so the comparison uses the remaining headroom instead.
+ * @param to The receiver whose resulting balance is checked.
+ * @param value The amount that would be received.
+ * @return balanceAvailable False when the balance could not be read; `exceeded` is then
+ * meaningless and the caller should treat the check as failed.
+ * @return exceeded True when the transfer would push `to` past {maxBalance}.
+ */
+ function _capExceeded(address to, uint256 value)
+ internal
+ view
+ virtual
+ returns (bool balanceAvailable, bool exceeded)
+ {
+ // Burns cannot breach a maximum, and address(0) is the sentinel rather than a holder.
+ // Exempt receivers may hold any amount. Neither reads a balance.
+ if (to == address(0) || _isAddressListed(to)) {
+ return (true, false);
+ }
+ uint256 balance;
+ (balanceAvailable, balance) = _balanceOf(to);
+ if (!balanceAvailable) {
+ return (false, false);
+ }
+ uint256 cap = maxBalance;
+ return (true, balance > cap || value > cap - balance);
+ }
+}
diff --git a/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol b/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol
new file mode 100644
index 00000000..3b371d7e
--- /dev/null
+++ b/src/rules/validation/abstract/core/ChainlinkPoRFeedManager.sol
@@ -0,0 +1,259 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleChainlinkPoRInvariantStorage} from "../invariant/RuleChainlinkPoRInvariantStorage.sol";
+import {IERC1404Extend} from "CMTAT/interfaces/tokenization/draft-IERC1404.sol";
+import {AggregatorV3Interface} from "../../../interfaces/AggregatorV3Interface.sol";
+import {IDecimals} from "../../../interfaces/IDecimals.sol";
+import {ITotalSupply} from "../../../interfaces/ITotalSupply.sol";
+import {TokenSupplyReader} from "./TokenSupplyReader.sol";
+
+/**
+ * @title ChainlinkPoRFeedManager
+ * @notice Configuration and reading of a Chainlink Proof of Reserve feed: which feed, which token it
+ * backs, how stale an answer may be, and how to scale it into token units.
+ *
+ * @dev Declares **no constructor** and does not depend on **ERC-1404**, so the inheriting rule
+ * decides when configuration happens (constructor or initializer) and owns the code-to-message
+ * mapping; {_maxBackedSupply} returns a plain `uint8` describing why an answer is unusable.
+ *
+ * @dev The feed's `decimals()` is read **live on every check, never cached**. Caching saves one call
+ * but lets a feed that changes decimals mis-scale reserves by `10 ** delta` with no on-chain signal
+ * -- in the overstating direction that silently authorises unbacked minting.
+ *
+ * @dev {_maxBackedSupply} must never revert, because the rule calls it from MUST-NOT-revert views,
+ * so every feed interaction is `try/catch`-wrapped. That is only safe because the setters require
+ * both the feed and the token to have code and EIP-6780 makes it permanent: a `try` to a codeless
+ * address reverts *uncatchably*. Assumes a Cancun-or-later chain.
+ */
+abstract contract ChainlinkPoRFeedManager is TokenSupplyReader, RuleChainlinkPoRInvariantStorage {
+ /**
+ * @notice The Proof of Reserve data feed consulted before every mint.
+ */
+ AggregatorV3Interface public reservesFeed;
+ /**
+ * @dev tokenContract is trusted to return a correct totalSupply.
+ */
+ ITotalSupply public tokenContract;
+ /**
+ * @notice Decimals of the protected token, used to scale the reserve answer.
+ */
+ uint8 public tokenDecimals;
+ /**
+ * @notice Maximum accepted age of the reserve data, in seconds; 0 disables the staleness check.
+ */
+ uint256 public maxStalenessSeconds;
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ modifier onlyChainlinkPoRManager() {
+ _authorizeChainlinkPoRManager();
+ _;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Sets the Proof of Reserve data feed and caches its decimals.
+ * @dev The feed must be a contract whose `decimals()` call succeeds and reports at most
+ * {MAX_FEED_DECIMALS}; both are validated here so the read path stays revert-free.
+ * @param newReservesFeed The new data feed.
+ */
+ function setReservesFeed(AggregatorV3Interface newReservesFeed) public virtual onlyChainlinkPoRManager {
+ _setReservesFeed(newReservesFeed);
+ }
+
+ /**
+ * @notice Sets the protected token and the decimals used to scale the reserve answer.
+ * @param newTokenContract The new token contract; must not be the zero address.
+ * @param newTokenDecimals The token decimals; must be at most {MAX_TOKEN_DECIMALS} and, when the
+ * token exposes `decimals()`, must match it. `0` is valid and common for CMTAT equity tokens.
+ */
+ function setTokenMetadata(address newTokenContract, uint8 newTokenDecimals) public virtual onlyChainlinkPoRManager {
+ _setTokenMetadata(newTokenContract, newTokenDecimals);
+ }
+
+ /**
+ * @notice Sets the maximum accepted age of the reserve data.
+ * @param newMaxStalenessSeconds The new threshold in seconds; 0 disables the staleness check.
+ */
+ function setMaxStalenessSeconds(uint256 newMaxStalenessSeconds) public virtual onlyChainlinkPoRManager {
+ _setMaxStalenessSeconds(newMaxStalenessSeconds);
+ }
+
+ /**
+ * @notice Returns the decimals currently reported by {reservesFeed}.
+ * @dev Read live from the feed rather than from storage, so it always agrees with what the
+ * restriction checks use. Unlike the ERC-1404 views this getter is allowed to revert: it
+ * forwards whatever the feed does, which is the honest answer for a diagnostic accessor.
+ * @return The feed's current decimals.
+ */
+ function feedDecimals() public view virtual returns (uint8) {
+ return reservesFeed.decimals();
+ }
+
+ /**
+ * @notice Returns the supply currently backed by the reserves, i.e. the maximum total supply a
+ * mint may reach. This is the reported reserves scaled into token units, with no margin applied.
+ * @dev Mirrors what the rule's restriction check computes, so integrators can preview the limit
+ * without simulating a mint. Never reverts.
+ * @return restrictionCode `0` when the feed answer is usable, otherwise the restriction code
+ * that a mint would return.
+ * @return backedSupply The backed supply expressed in token units; meaningless when
+ * `restrictionCode` is non-zero.
+ */
+ function maxBackedSupply() public view virtual returns (uint8 restrictionCode, uint256 backedSupply) {
+ return _maxBackedSupply();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Stores the data feed and emits {ReservesFeedUpdated}.
+ * @dev The feed's `decimals()` is validated here so a misconfigured feed is rejected up front
+ * rather than silently blocking every mint later, but the value is deliberately NOT cached --
+ * {_maxBackedSupply} re-reads it on every check. The emitted decimals are informational: they
+ * record what the feed reported at configuration time.
+ * @param newReservesFeed The new data feed.
+ */
+ function _setReservesFeed(AggregatorV3Interface newReservesFeed) internal virtual {
+ address feed = address(newReservesFeed);
+ require(feed != address(0), RuleChainlinkPoR_FeedAddressZeroNotAllowed());
+ require(feed.code.length != 0, RuleChainlinkPoR_FeedIsNotAContract(feed));
+ uint8 newFeedDecimals;
+ try newReservesFeed.decimals() returns (uint8 decimals_) {
+ newFeedDecimals = decimals_;
+ } catch {
+ revert RuleChainlinkPoR_FeedDecimalsUnavailable(feed);
+ }
+ require(newFeedDecimals <= MAX_FEED_DECIMALS, RuleChainlinkPoR_FeedDecimalsTooLarge(newFeedDecimals));
+ reservesFeed = newReservesFeed;
+ emit ReservesFeedUpdated(feed, newFeedDecimals);
+ }
+
+ /**
+ * @notice Stores the protected token and its decimals and emits {TokenMetadataUpdated}.
+ * @dev When the token exposes `decimals()`, the provided value must match it; otherwise the
+ * provided value is used as-is. WARNING: an incorrect value for a token that does not expose
+ * `decimals()` skews the reserve comparison in either direction.
+ * @param newTokenContract The new token contract.
+ * @param newTokenDecimals The token decimals.
+ */
+ function _setTokenMetadata(address newTokenContract, uint8 newTokenDecimals) internal virtual {
+ require(newTokenContract != address(0), RuleChainlinkPoR_TokenAddressZeroNotAllowed());
+ // Explicit, rather than relying on the uncatchable extcodesize revert that the `decimals()`
+ // probe below happens to produce for a codeless address: that is compiler behaviour, not a
+ // check, and it would vanish if the probe were ever rewritten as a low-level staticcall.
+ require(newTokenContract.code.length != 0, RuleChainlinkPoR_TokenIsNotAContract(newTokenContract));
+ require(newTokenDecimals <= MAX_TOKEN_DECIMALS, RuleChainlinkPoR_InvalidTokenDecimals(newTokenDecimals));
+ try IDecimals(newTokenContract).decimals() returns (uint8 onChainDecimals) {
+ require(
+ onChainDecimals == newTokenDecimals,
+ RuleChainlinkPoR_TokenDecimalsMismatch(newTokenDecimals, onChainDecimals)
+ );
+ } catch {
+ // The token does not expose `decimals()`; the provided value is used as-is.
+ }
+ // `totalSupply()` is mandatory, unlike `decimals()`: the restriction check cannot work
+ // without it. Probing here turns a silent read-path failure into a configuration error.
+ require(
+ _probeTotalSupplyCallable(newTokenContract), RuleChainlinkPoR_TokenTotalSupplyUnavailable(newTokenContract)
+ );
+ tokenContract = ITotalSupply(newTokenContract);
+ tokenDecimals = newTokenDecimals;
+ emit TokenMetadataUpdated(newTokenContract, newTokenDecimals);
+ }
+
+ /**
+ * @notice Stores the staleness threshold and emits {MaxStalenessSecondsUpdated}.
+ * @param newMaxStalenessSeconds The new threshold in seconds; 0 disables the check.
+ */
+ function _setMaxStalenessSeconds(uint256 newMaxStalenessSeconds) internal virtual {
+ maxStalenessSeconds = newMaxStalenessSeconds;
+ emit MaxStalenessSecondsUpdated(newMaxStalenessSeconds);
+ }
+
+ /**
+ * @notice Authorization hook invoked before any configuration change.
+ * @dev Implemented by concrete subclasses with the desired access-control policy.
+ */
+ function _authorizeChainlinkPoRManager() internal view virtual;
+
+ /**
+ * @notice Reads the feed and derives the supply currently backed by the reserves.
+ * @dev Never reverts: the feed address is code-checked and the call is wrapped in `try/catch`.
+ * @return restrictionCode `0` when the answer is usable, otherwise the reason it is not.
+ * @return backedSupply The backed supply in token units; `0` when `restrictionCode` is non-zero.
+ */
+ function _maxBackedSupply() internal view virtual returns (uint8 restrictionCode, uint256 backedSupply) {
+ AggregatorV3Interface feed = reservesFeed;
+ // Read live, never cached: see the contract-level note on why the extra call is worth it.
+ // No code-length guard: `_setReservesFeed` requires code and EIP-6780 makes that permanent.
+ uint8 currentFeedDecimals;
+ try feed.decimals() returns (uint8 decimals_) {
+ currentFeedDecimals = decimals_;
+ } catch {
+ return (CODE_RESERVES_FEED_UNAVAILABLE, 0);
+ }
+ // Re-checked at read time, not just at configuration: a feed that raised its decimals past
+ // the bound would otherwise overflow the scaling exponent and revert this view.
+ if (currentFeedDecimals > MAX_FEED_DECIMALS) {
+ return (CODE_RESERVES_FEED_UNAVAILABLE, 0);
+ }
+ try feed.latestRoundData() returns (uint80, int256 answer, uint256, uint256 updatedAt, uint80) {
+ // A negative reserve is meaningless and `updatedAt == 0` marks a round that never completed.
+ if (answer < 0 || updatedAt == 0) {
+ return (CODE_RESERVES_ANSWER_INVALID, 0);
+ }
+ uint256 staleness = maxStalenessSeconds;
+ if (staleness != 0 && block.timestamp > updatedAt && block.timestamp - updatedAt > staleness) {
+ return (CODE_RESERVES_FEED_STALE, 0);
+ }
+ // `answer >= 0` was just checked, so the cast to uint256 preserves the value.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ uint256 backed = _scaleReserve(uint256(answer), currentFeedDecimals);
+ return (uint8(IERC1404Extend.REJECTED_CODE_BASE.TRANSFER_OK), backed);
+ } catch {
+ return (CODE_RESERVES_FEED_UNAVAILABLE, 0);
+ }
+ }
+
+ /**
+ * @inheritdoc TokenSupplyReader
+ */
+ function _supplyToken() internal view virtual override returns (ITotalSupply) {
+ return tokenContract;
+ }
+
+ /**
+ * @notice Converts a reserve answer from the feed's decimals to the token's decimals.
+ * @dev Saturates at `type(uint256).max` instead of overflowing: this function is on a
+ * MUST-NOT-revert read path, and a reserve that large backs any representable supply anyway.
+ * Scaling down truncates, which rounds the backed supply in the conservative direction.
+ * @param answer The raw feed answer, expressed with `from` decimals.
+ * @param from The feed's decimals, as read live for this check.
+ * @return The reserve expressed with {tokenDecimals} decimals.
+ */
+ function _scaleReserve(uint256 answer, uint8 from) internal view virtual returns (uint256) {
+ uint8 to = tokenDecimals;
+ if (to == from) {
+ return answer;
+ }
+ if (to > from) {
+ // to <= MAX_TOKEN_DECIMALS, so the factor is at most 10 ** 18.
+ uint256 factor = 10 ** uint256(to - from);
+ if (answer > type(uint256).max / factor) {
+ return type(uint256).max;
+ }
+ return answer * factor;
+ }
+ // `from` was bounded by MAX_FEED_DECIMALS above, so the divisor cannot overflow.
+ return answer / (10 ** uint256(from - to));
+ }
+}
diff --git a/src/rules/validation/abstract/core/RuleNFTAdapter.sol b/src/rules/validation/abstract/core/RuleNFTAdapter.sol
index 334dc01d..47d70dd0 100644
--- a/src/rules/validation/abstract/core/RuleNFTAdapter.sol
+++ b/src/rules/validation/abstract/core/RuleNFTAdapter.sol
@@ -152,6 +152,7 @@ abstract contract RuleNFTAdapter is RuleTransferValidation, IERC7943NonFungibleC
)
public
view
+ virtual
override(IERC7943NonFungibleCompliance)
returns (bool)
{
diff --git a/src/rules/validation/abstract/core/RuleTransferValidation.sol b/src/rules/validation/abstract/core/RuleTransferValidation.sol
index cacc2803..74aa3871 100644
--- a/src/rules/validation/abstract/core/RuleTransferValidation.sol
+++ b/src/rules/validation/abstract/core/RuleTransferValidation.sol
@@ -67,6 +67,7 @@ abstract contract RuleTransferValidation is
function canTransfer(address from, address to, uint256 amount)
public
view
+ virtual
override(IERC3643ComplianceRead)
returns (bool isValid)
{
diff --git a/src/rules/validation/abstract/core/RuleWhitelistShared.sol b/src/rules/validation/abstract/core/RuleWhitelistShared.sol
index 7bb55b91..fa4bd993 100644
--- a/src/rules/validation/abstract/core/RuleWhitelistShared.sol
+++ b/src/rules/validation/abstract/core/RuleWhitelistShared.sol
@@ -48,6 +48,11 @@ abstract contract RuleWhitelistShared is RuleNFTAdapter, RuleWhitelistInvariantS
_;
}
+ modifier onlyCheckSpenderManager() {
+ _authorizeCheckSpenderManager();
+ _;
+ }
+
/*//////////////////////////////////////////////////////////////
EXTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
@@ -98,6 +103,15 @@ abstract contract RuleWhitelistShared is RuleNFTAdapter, RuleWhitelistInvariantS
PUBLIC FUNCTIONS
//////////////////////////////////////////////////////////////*/
+ /**
+ * @notice Enables or disables spender verification on delegated transfers.
+ * @dev Restricted to the check-spender manager; emits {CheckSpenderUpdated}.
+ * @param value The new state of the `checkSpender` flag.
+ */
+ function setCheckSpender(bool value) public virtual onlyCheckSpenderManager {
+ _setCheckSpender(value);
+ }
+
/**
* @notice Enables or disables minting through this rule.
* @param value The new value of the `allowMint` flag.
@@ -150,6 +164,19 @@ abstract contract RuleWhitelistShared is RuleNFTAdapter, RuleWhitelistInvariantS
INTERNAL FUNCTIONS
//////////////////////////////////////////////////////////////*/
+ /**
+ * @notice Internal helper to update the {checkSpender} flag and emit {CheckSpenderUpdated}.
+ * @dev The event lives here rather than at the call site so the constructors of the inheriting
+ * rules announce the initial value too, matching {_setAllowMintBurn}. Without it an indexer
+ * could reconstruct `allowMint` and `allowBurn` from genesis but had to special-case
+ * `checkSpender` (`CLAUDE_ANALYSIS.md` C-2).
+ * @param value New flag value.
+ */
+ function _setCheckSpender(bool value) internal virtual {
+ checkSpender = value;
+ emit CheckSpenderUpdated(value);
+ }
+
/**
* @notice Sets both mint/burn flags at once (deployment helper).
* @param allowMint_ Whether minting is permitted.
@@ -184,6 +211,13 @@ abstract contract RuleWhitelistShared is RuleNFTAdapter, RuleWhitelistInvariantS
*/
function _authorizeMintBurnManager() internal view virtual;
+ /**
+ * @notice Authorizes the caller as check-spender manager; reverts otherwise.
+ * @dev Implemented by concrete subclasses with the desired access-control policy.
+ * `view` by convention: an access-control hook checks and reverts, it never mutates state.
+ */
+ function _authorizeCheckSpenderManager() internal view virtual;
+
/**
* @inheritdoc RuleNFTAdapter
*/
diff --git a/src/rules/validation/abstract/core/TokenSupplyReader.sol b/src/rules/validation/abstract/core/TokenSupplyReader.sol
new file mode 100644
index 00000000..4fc5c979
--- /dev/null
+++ b/src/rules/validation/abstract/core/TokenSupplyReader.sol
@@ -0,0 +1,72 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {ITotalSupply} from "../../../interfaces/ITotalSupply.sol";
+
+/**
+ * @title TokenSupplyReader
+ * @notice Revert-free `totalSupply()` read shared by {RuleMaxTotalSupplyBase} and
+ * {RuleChainlinkPoRBase}, which both cap minting against a foreign token's supply.
+ *
+ * @dev Declares **no storage**: each rule keeps its own token variable and implements
+ * {_supplyToken}. Holding it here would reorder every inheriting rule's slots for no benefit.
+ *
+ * @dev Only the `try/catch` probe is shared, not the validation. Each rule composes
+ * {_probeTotalSupplyCallable} with its own `require`s so its three configuration failures keep three
+ * distinct, named errors, per the one-error-namespace-per-rule convention.
+ *
+ * @dev **Deployment precondition.** {_currentSupply} performs no code-length check, because a `try`
+ * to a codeless address reverts *uncatchably* -- the ABI decoder fails in this frame after the call
+ * returns 0 bytes, out of `catch`'s reach (not `EXTCODESIZE`, which solc >= 0.8.10 skips when return
+ * data is expected). Safety comes from configuration: each setter rejects a codeless candidate and
+ * EIP-6780 makes that permanent. **Assumes a Cancun-or-later chain**; on an older one a validated
+ * token could still become codeless and the ERC-1404 views would revert.
+ */
+abstract contract TokenSupplyReader {
+ /**
+ * @notice The token whose `totalSupply()` this rule reads.
+ * @dev Implemented by each rule against its own storage, so this base stays stateless.
+ * @return The configured token.
+ */
+ function _supplyToken() internal view virtual returns (ITotalSupply);
+
+ /**
+ * @notice Reads the configured token's current total supply without ever reverting.
+ * @dev Wrapped in `try/catch` so the ERC-1404 / ERC-3643 read path stays revert-free if the token
+ * breaks after configuration -- a proxy upgraded to something that reverts, or a pausable
+ * implementation that reverts while paused. Configuration already probes `totalSupply()`, so
+ * reaching the failure branch means the token changed behaviour since. Callers translate
+ * `available == false` into their own "supply unavailable" restriction code.
+ * @return available True when the supply could be read.
+ * @return supply The total supply; meaningless when `available` is false.
+ */
+ function _currentSupply() internal view virtual returns (bool available, uint256 supply) {
+ try _supplyToken().totalSupply() returns (uint256 totalSupply_) {
+ return (true, totalSupply_);
+ } catch {
+ return (false, 0);
+ }
+ }
+
+ /**
+ * @notice Returns whether `candidate` answers `totalSupply()` without reverting.
+ * @dev Used at configuration time to turn what would otherwise be a silent read-path failure
+ * into an immediate, named error raised by the calling rule. `totalSupply()` is mandatory for
+ * both rules -- the cap check cannot work without it -- unlike `decimals()`, which only
+ * `RuleChainlinkPoR` consults and treats as optional.
+ *
+ * WARNING: the caller MUST have already established that `candidate` has code. A `try` call to a
+ * codeless address reverts uncatchably -- the ABI decoder fails in the caller's frame, outside `catch`'s
+ * reach -- and this probe cannot contain it. Note code alone is not sufficient either: a contract that
+ * returns 0 bytes fails the same way.
+ * @param candidate The token contract to probe.
+ * @return True when `totalSupply()` is callable.
+ */
+ function _probeTotalSupplyCallable(address candidate) internal view virtual returns (bool) {
+ try ITotalSupply(candidate).totalSupply() returns (uint256) {
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/src/rules/validation/abstract/core/TotalSupplyCapManager.sol b/src/rules/validation/abstract/core/TotalSupplyCapManager.sol
new file mode 100644
index 00000000..7d1b3137
--- /dev/null
+++ b/src/rules/validation/abstract/core/TotalSupplyCapManager.sol
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleMaxTotalSupplyInvariantStorage} from "../invariant/RuleMaxTotalSupplyInvariantStorage.sol";
+import {ITotalSupply} from "../../../interfaces/ITotalSupply.sol";
+import {TokenSupplyReader} from "./TokenSupplyReader.sol";
+
+/**
+ * @title TotalSupplyCapManager
+ * @notice A static total-supply ceiling: which token to observe, what the cap is, and whether a
+ * prospective mint fits under it.
+ *
+ * @dev Declares **no constructor** and does not depend on **ERC-1404**, so the inheriting rule
+ * decides when configuration happens (constructor or initializer) and owns the restriction-code
+ * mapping; {_capExceeded} answers in booleans.
+ *
+ * @dev The revert-free `totalSupply()` read and the configuration probe come from
+ * {TokenSupplyReader} via {_supplyToken}; the deployment precondition documented there applies
+ * unchanged.
+ */
+abstract contract TotalSupplyCapManager is TokenSupplyReader, RuleMaxTotalSupplyInvariantStorage {
+ /**
+ * @dev tokenContract is trusted to report an *accurate* totalSupply -- nothing on-chain can
+ * verify that -- but it is NOT trusted to stay callable: a reverting or codeless token yields
+ * {CODE_SUPPLY_ORACLE_UNAVAILABLE} instead of reverting the MUST-NOT-revert views.
+ */
+ ITotalSupply public tokenContract;
+ /**
+ * @notice Maximum total supply; minting that would exceed this value is rejected.
+ */
+ uint256 public maxTotalSupply;
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ modifier onlyMaxTotalSupplyManager() {
+ _authorizeMaxTotalSupplyManager();
+ _;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Updates the maximum total supply.
+ * @param newMaxTotalSupply New maximum total supply value.
+ */
+ function setMaxTotalSupply(uint256 newMaxTotalSupply) public virtual onlyMaxTotalSupplyManager {
+ _setMaxTotalSupply(newMaxTotalSupply);
+ }
+
+ /**
+ * @notice Updates the token contract whose total supply is checked.
+ * @param newTokenContract New token contract address; must not be the zero address.
+ */
+ function setTokenContract(address newTokenContract) public virtual onlyMaxTotalSupplyManager {
+ _setTokenContract(newTokenContract);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Stores the supply cap and emits {MaxTotalSupplyUpdated}.
+ * @dev Shared by the constructor and {setMaxTotalSupply} so the event is emitted on every
+ * assignment, including the initial one.
+ * @param newMaxTotalSupply The new maximum total supply.
+ */
+ function _setMaxTotalSupply(uint256 newMaxTotalSupply) internal virtual {
+ maxTotalSupply = newMaxTotalSupply;
+ emit MaxTotalSupplyUpdated(newMaxTotalSupply);
+ }
+
+ /**
+ * @notice Validates and stores the observed token and emits {TokenContractUpdated}.
+ * @dev Shared by the constructor and {setTokenContract}; see {_setMaxTotalSupply}.
+ * @param newTokenContract The new token contract.
+ */
+ function _setTokenContract(address newTokenContract) internal virtual {
+ _validateTokenContract(newTokenContract);
+ tokenContract = ITotalSupply(newTokenContract);
+ emit TokenContractUpdated(newTokenContract);
+ }
+
+ /**
+ * @notice Validates a candidate token contract before it is stored.
+ * @dev `totalSupply()` is mandatory -- the cap check cannot work without it -- so it is probed
+ * here, turning what would otherwise be a silent read-path failure into a named configuration
+ * error. The code-length check is explicit rather than relying on the uncatchable extcodesize
+ * revert that the probe would incidentally produce.
+ * @param candidate The token contract to validate.
+ */
+ function _validateTokenContract(address candidate) internal view virtual {
+ require(candidate != address(0), RuleMaxTotalSupply_TokenAddressZeroNotAllowed());
+ require(candidate.code.length != 0, RuleMaxTotalSupply_TokenIsNotAContract(candidate));
+ require(_probeTotalSupplyCallable(candidate), RuleMaxTotalSupply_TokenTotalSupplyUnavailable(candidate));
+ }
+
+ /**
+ * @notice Authorization hook invoked before updating the max total supply or token contract.
+ * @dev Implemented by concrete subclasses with the desired access-control policy.
+ */
+ function _authorizeMaxTotalSupplyManager() internal view virtual;
+
+ /**
+ * @inheritdoc TokenSupplyReader
+ */
+ function _supplyToken() internal view virtual override returns (ITotalSupply) {
+ return tokenContract;
+ }
+
+ /**
+ * @notice Reports whether minting `value` would breach the cap, without ever reverting.
+ * @dev Answers in booleans rather than restriction codes, so the caller owns the ERC-1404
+ * mapping. Overflow-safe: `currentSupply + value` could exceed uint256 on a MUST-NOT-revert
+ * path, so the comparison uses the remaining headroom instead.
+ * @param value The amount that would be minted.
+ * @return supplyAvailable False when `totalSupply()` could not be read; the other return value
+ * is then meaningless and the caller should treat the check as failed.
+ * @return exceeded True when the mint would push total supply past {maxTotalSupply}.
+ */
+ function _capExceeded(uint256 value) internal view virtual returns (bool supplyAvailable, bool exceeded) {
+ uint256 currentSupply;
+ (supplyAvailable, currentSupply) = _currentSupply();
+ if (!supplyAvailable) {
+ return (false, false);
+ }
+ uint256 cap = maxTotalSupply;
+ return (true, currentSupply > cap || value > cap - currentSupply);
+ }
+}
diff --git a/src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol b/src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol
new file mode 100644
index 00000000..478fa657
--- /dev/null
+++ b/src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol
@@ -0,0 +1,119 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleSharedInvariantStorage} from "./RuleSharedInvariantStorage.sol";
+
+/**
+ * @title RuleChainlinkPoRInvariantStorage — constants, events and errors for the Chainlink
+ * Proof of Reserve rule.
+ */
+abstract contract RuleChainlinkPoRInvariantStorage is RuleSharedInvariantStorage {
+ /* ============ Constants ============ */
+
+ /**
+ * @notice Upper bound accepted for a data feed's `decimals()` value.
+ * @dev Chainlink feeds report 8 or 18 decimals. The bound keeps `10 ** (feedDecimals -
+ * tokenDecimals)` inside `uint256` so the read path can never revert on exponentiation.
+ */
+ uint8 public constant MAX_FEED_DECIMALS = 36;
+
+ /**
+ * @notice Upper bound accepted for the protected token's `decimals()` value.
+ * @dev `0` is a valid lower bound: CMTAT equity tokens commonly report 0 decimals.
+ */
+ uint8 public constant MAX_TOKEN_DECIMALS = 18;
+
+ /* ============ String messages ============ */
+
+ /**
+ * @notice Restriction message returned when the mint is not backed by the reported reserves.
+ */
+ string constant TEXT_RESERVES_EXCEEDED = "Mint would exceed the proof of reserve backing";
+ /**
+ * @notice Restriction message returned when the reserve data is older than the staleness threshold.
+ */
+ string constant TEXT_RESERVES_FEED_STALE = "Proof of reserve data is stale";
+ /**
+ * @notice Restriction message returned when the feed answered but the answer is unusable.
+ */
+ string constant TEXT_RESERVES_ANSWER_INVALID = "Proof of reserve answer is invalid";
+ /**
+ * @notice Restriction message returned when the feed could not be read at all.
+ */
+ string constant TEXT_RESERVES_FEED_UNAVAILABLE = "Proof of reserve feed is unavailable";
+ /**
+ * @notice Restriction message returned when the token's total supply cannot be read.
+ */
+ string constant TEXT_TOTAL_SUPPLY_UNAVAILABLE = "Token total supply is unavailable";
+
+ /* ============ Restriction codes ============ */
+
+ // It is very important that each rule uses an unique code
+ /**
+ * @notice Restriction code returned when the new total supply would exceed the backed supply.
+ */
+ uint8 public constant CODE_RESERVES_EXCEEDED = 75;
+ /**
+ * @notice Restriction code returned when the feed has not been updated within the staleness threshold.
+ */
+ uint8 public constant CODE_RESERVES_FEED_STALE = 76;
+ /**
+ * @notice Restriction code returned when the feed responded but the answer cannot be used:
+ * a negative reserve, or an incomplete round (`updatedAt == 0`).
+ * @dev Distinct from {CODE_RESERVES_FEED_UNAVAILABLE}: here a round *was* returned, so the feed
+ * is reachable and the problem is the data. An operator seeing this checks whether the
+ * configured address is really a Proof of Reserve feed, or waits for the round to complete.
+ */
+ uint8 public constant CODE_RESERVES_ANSWER_INVALID = 77;
+ /**
+ * @notice Restriction code returned when `tokenContract.totalSupply()` reverts or the token has
+ * lost its code, so the current supply cannot be established.
+ * @dev Fail-closed: without a supply figure the backing cannot be verified, so the mint is
+ * blocked rather than assumed safe.
+ */
+ uint8 public constant CODE_TOTAL_SUPPLY_UNAVAILABLE = 78;
+ /**
+ * @notice Restriction code returned when no usable response could be obtained from the feed at
+ * all: `decimals()` or `latestRoundData()` reverted, or the feed reports more than
+ * {MAX_FEED_DECIMALS}.
+ * @dev Distinct from {CODE_RESERVES_ANSWER_INVALID}: there is no answer to judge. An operator
+ * seeing this checks feed liveness and whether the address is a compatible aggregator.
+ */
+ uint8 public constant CODE_RESERVES_FEED_UNAVAILABLE = 79;
+
+ /* ============ Events ============ */
+
+ /**
+ * @notice Emitted when the Proof of Reserve data feed is set or replaced.
+ * @param newReservesFeed Address of the newly configured data feed.
+ * @param feedDecimals Decimals reported by that feed, cached at configuration time.
+ */
+ event ReservesFeedUpdated(address indexed newReservesFeed, uint8 feedDecimals);
+ /**
+ * @notice Emitted when the protected token or its decimals are updated.
+ * @param newTokenContract Address of the token whose `totalSupply` is checked.
+ * @param newTokenDecimals Decimals used to scale the reserve value.
+ */
+ event TokenMetadataUpdated(address indexed newTokenContract, uint8 newTokenDecimals);
+ /**
+ * @notice Emitted when the staleness threshold is updated.
+ * @param newMaxStalenessSeconds New maximum accepted age of the reserve data, in seconds; 0 disables the check.
+ */
+ event MaxStalenessSecondsUpdated(uint256 newMaxStalenessSeconds);
+
+ /* ============ Errors ============ */
+
+ error RuleChainlinkPoR_InvalidTransfer(address rule, address from, address to, uint256 value, uint8 code);
+ error RuleChainlinkPoR_InvalidTransferFrom(
+ address rule, address spender, address from, address to, uint256 value, uint8 code
+ );
+ error RuleChainlinkPoR_FeedAddressZeroNotAllowed();
+ error RuleChainlinkPoR_FeedIsNotAContract(address feed);
+ error RuleChainlinkPoR_FeedDecimalsUnavailable(address feed);
+ error RuleChainlinkPoR_FeedDecimalsTooLarge(uint8 feedDecimals);
+ error RuleChainlinkPoR_TokenAddressZeroNotAllowed();
+ error RuleChainlinkPoR_TokenIsNotAContract(address token);
+ error RuleChainlinkPoR_TokenTotalSupplyUnavailable(address token);
+ error RuleChainlinkPoR_InvalidTokenDecimals(uint8 tokenDecimals);
+ error RuleChainlinkPoR_TokenDecimalsMismatch(uint8 provided, uint8 onChain);
+}
diff --git a/src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol b/src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol
new file mode 100644
index 00000000..a691ed7a
--- /dev/null
+++ b/src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleSharedInvariantStorage} from "./RuleSharedInvariantStorage.sol";
+
+/**
+ * @title RuleMaxBalanceInvariantStorage — constants, events and errors for the max-balance rule.
+ */
+abstract contract RuleMaxBalanceInvariantStorage is RuleSharedInvariantStorage {
+ /**
+ * @notice Role allowed to change the cap, the observed token and the exemption list.
+ */
+ bytes32 public constant MAX_BALANCE_ROLE = keccak256("MAX_BALANCE_ROLE");
+
+ /**
+ * @notice Restriction message returned when the receiver's balance would exceed the cap.
+ */
+ string constant TEXT_MAX_BALANCE_EXCEEDED = "Recipient balance would exceed the maximum";
+ /**
+ * @notice Restriction message returned when the receiver's balance cannot be read.
+ */
+ string constant TEXT_BALANCE_UNAVAILABLE = "Token balance is unavailable";
+
+ // It is very important that each rule uses an unique code
+ /**
+ * @notice Restriction code returned when the transfer would push the receiver above {maxBalance}.
+ */
+ uint8 public constant CODE_MAX_BALANCE_EXCEEDED = 82;
+ /**
+ * @notice Restriction code returned when `balanceToken.balanceOf(to)` reverts or the token has
+ * lost its code, so the receiver's balance cannot be established.
+ * @dev Fail-closed: without a balance the cap cannot be verified, so the transfer is blocked
+ * rather than assumed safe.
+ */
+ uint8 public constant CODE_BALANCE_UNAVAILABLE = 83;
+
+ /**
+ * @notice Emitted when the maximum balance per holder is updated.
+ * @param newMaxBalance The new cap, in token units.
+ */
+ event MaxBalanceUpdated(uint256 newMaxBalance);
+ /**
+ * @notice Emitted when the observed token contract is updated.
+ * @dev Named distinctly from `RuleMaxTotalSupply`'s `TokenContractUpdated`: `HelperContract`
+ * inherits both invariant-storage contracts and identical identifiers would clash.
+ * @param newBalanceToken Address of the newly configured token contract.
+ */
+ event MaxBalanceTokenUpdated(address indexed newBalanceToken);
+ /**
+ * @notice Emitted when an address is exempted from the cap.
+ * @param targetAddress The newly exempt address.
+ */
+ event ExemptAddressAdded(address indexed targetAddress);
+ /**
+ * @notice Emitted when an address loses its exemption.
+ * @param targetAddress The address that is no longer exempt.
+ */
+ event ExemptAddressRemoved(address indexed targetAddress);
+ /**
+ * @notice Emitted when several addresses are exempted in one call.
+ * @param targetAddresses The submitted addresses.
+ * @param added Number of addresses that were not already exempt.
+ * @param skipped Number of addresses that were already exempt.
+ */
+ event ExemptAddressesAdded(address[] targetAddresses, uint256 added, uint256 skipped);
+ /**
+ * @notice Emitted when several addresses lose their exemption in one call.
+ * @param targetAddresses The submitted addresses.
+ * @param removed Number of addresses that were exempt.
+ * @param skipped Number of addresses that were not exempt.
+ */
+ event ExemptAddressesRemoved(address[] targetAddresses, uint256 removed, uint256 skipped);
+
+ error RuleMaxBalance_InvalidTransfer(address rule, address from, address to, uint256 value, uint8 code);
+ error RuleMaxBalance_InvalidTransferFrom(
+ address rule, address spender, address from, address to, uint256 value, uint8 code
+ );
+ error RuleMaxBalance_TokenAddressZeroNotAllowed();
+ error RuleMaxBalance_TokenIsNotAContract(address token);
+ error RuleMaxBalance_TokenBalanceUnavailable(address token);
+}
diff --git a/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol b/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol
index 02a8be52..7979a2c3 100644
--- a/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol
+++ b/src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol
@@ -11,12 +11,25 @@ abstract contract RuleMaxTotalSupplyInvariantStorage is RuleSharedInvariantStora
* @notice Restriction message returned when the max total supply would be exceeded.
*/
string constant TEXT_MAX_TOTAL_SUPPLY_EXCEEDED = "Max total supply exceeded";
+ /**
+ * @notice Restriction message returned when the token's total supply cannot be read.
+ * @dev Named differently from `RuleChainlinkPoR`'s equivalent on purpose: `HelperContract`
+ * inherits both invariant-storage contracts, and identical identifiers would clash.
+ */
+ string constant TEXT_SUPPLY_ORACLE_UNAVAILABLE = "Token total supply is unavailable";
// It is very important that each rule uses an unique code
/**
* @notice Restriction code returned when the max total supply would be exceeded.
*/
uint8 public constant CODE_MAX_TOTAL_SUPPLY_EXCEEDED = 50;
+ /**
+ * @notice Restriction code returned when `tokenContract.totalSupply()` reverts or the token has
+ * lost its code, so the current supply cannot be established.
+ * @dev Fail-closed: without a supply figure the cap cannot be verified, so the mint is blocked
+ * rather than assumed safe.
+ */
+ uint8 public constant CODE_SUPPLY_ORACLE_UNAVAILABLE = 51;
/**
* @notice Emitted when the maximum total supply is updated.
@@ -34,4 +47,6 @@ abstract contract RuleMaxTotalSupplyInvariantStorage is RuleSharedInvariantStora
address rule, address spender, address from, address to, uint256 value, uint8 code
);
error RuleMaxTotalSupply_TokenAddressZeroNotAllowed();
+ error RuleMaxTotalSupply_TokenIsNotAContract(address token);
+ error RuleMaxTotalSupply_TokenTotalSupplyUnavailable(address token);
}
diff --git a/src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol b/src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol
new file mode 100644
index 00000000..53d7746b
--- /dev/null
+++ b/src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol
@@ -0,0 +1,28 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {RuleSharedInvariantStorage} from "./RuleSharedInvariantStorage.sol";
+
+/**
+ * @title RuleReceiverWhitelistInvariantStorage — constants and error for the receiver-whitelist rule.
+ */
+abstract contract RuleReceiverWhitelistInvariantStorage is RuleSharedInvariantStorage {
+ // It is very important that each rule uses an unique code
+ /**
+ * @notice Restriction code returned when the receiver is not whitelisted.
+ * @dev Named `RECEIVER` rather than `TO` so it does not collide with `RuleWhitelist`'s
+ * `CODE_ADDRESS_TO_NOT_WHITELISTED` (22) when a test contract inherits both invariant stores.
+ */
+ uint8 public constant CODE_ADDRESS_RECEIVER_NOT_WHITELISTED = 81;
+ /**
+ * @notice Restriction message returned when the receiver is not whitelisted.
+ */
+ string constant TEXT_ADDRESS_RECEIVER_NOT_WHITELISTED = "ReceiverWhitelist: Receiver is not whitelisted";
+
+ error RuleReceiverWhitelist_InvalidTransfer(
+ address rule, address from, address to, uint256 value, uint8 restrictionCode
+ );
+ error RuleReceiverWhitelist_InvalidTransferFrom(
+ address rule, address spender, address from, address to, uint256 value, uint8 restrictionCode
+ );
+}
diff --git a/src/rules/validation/deployment/RuleChainlinkPoR.sol b/src/rules/validation/deployment/RuleChainlinkPoR.sol
new file mode 100644
index 00000000..c5ea1446
--- /dev/null
+++ b/src/rules/validation/deployment/RuleChainlinkPoR.sol
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol";
+import {AccessControlModuleStandalone} from "../../../modules/AccessControlModuleStandalone.sol";
+import {AggregatorV3Interface} from "../../interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoRBase} from "../abstract/base/RuleChainlinkPoRBase.sol";
+import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol";
+
+/**
+ * @title RuleChainlinkPoR
+ * @notice Restricts minting so that the token's total supply never exceeds the reserves reported by
+ * a Chainlink Proof of Reserve data feed.
+ */
+contract RuleChainlinkPoR is AccessControlModuleStandalone, RuleChainlinkPoRBase {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param admin Address that receives the default admin role.
+ * @param tokenContract_ Token contract that exposes totalSupply (must be non-zero).
+ * @param tokenDecimals_ Decimals of that token (0 to 18, checked against `decimals()` when exposed).
+ * @param reservesFeed_ Proof of Reserve data feed implementing `AggregatorV3Interface`.
+ * @param maxStalenessSeconds_ Initial staleness threshold in seconds; 0 disables the check.
+ */
+ constructor(
+ address admin,
+ address tokenContract_,
+ uint8 tokenDecimals_,
+ AggregatorV3Interface reservesFeed_,
+ uint256 maxStalenessSeconds_
+ )
+ AccessControlModuleStandalone(admin)
+ RuleChainlinkPoRBase(tokenContract_, tokenDecimals_, reservesFeed_, maxStalenessSeconds_)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(AccessControlEnumerable, RuleTransferValidation)
+ returns (bool)
+ {
+ return AccessControlEnumerable.supportsInterface(interfaceId)
+ || RuleTransferValidation.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts Proof of Reserve configuration to holders of DEFAULT_ADMIN_ROLE.
+ */
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyRole(DEFAULT_ADMIN_ROLE) {}
+}
diff --git a/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol b/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol
new file mode 100644
index 00000000..28f41ab5
--- /dev/null
+++ b/src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
+import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol";
+import {AggregatorV3Interface} from "../../interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoRBase} from "../abstract/base/RuleChainlinkPoRBase.sol";
+import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol";
+
+/**
+ * @title RuleChainlinkPoROwnable2Step
+ * @notice Ownable2Step variant of RuleChainlinkPoR.
+ */
+contract RuleChainlinkPoROwnable2Step is RuleChainlinkPoRBase, Ownable2Step, Ownable2StepERC165Module {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param owner Contract owner.
+ * @param tokenContract_ Token contract that exposes totalSupply (must be non-zero).
+ * @param tokenDecimals_ Decimals of that token (0 to 18, checked against `decimals()` when exposed).
+ * @param reservesFeed_ Proof of Reserve data feed implementing `AggregatorV3Interface`.
+ * @param maxStalenessSeconds_ Initial staleness threshold in seconds; 0 disables the check.
+ */
+ constructor(
+ address owner,
+ address tokenContract_,
+ uint8 tokenDecimals_,
+ AggregatorV3Interface reservesFeed_,
+ uint256 maxStalenessSeconds_
+ ) RuleChainlinkPoRBase(tokenContract_, tokenDecimals_, reservesFeed_, maxStalenessSeconds_) Ownable(owner) {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(RuleTransferValidation, Ownable2StepERC165Module)
+ returns (bool)
+ {
+ return Ownable2StepERC165Module.supportsInterface(interfaceId)
+ || RuleTransferValidation.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts Proof of Reserve configuration to the contract owner.
+ */
+ function _authorizeChainlinkPoRManager() internal view virtual override onlyOwner {}
+}
diff --git a/src/rules/validation/deployment/RuleERC2980.sol b/src/rules/validation/deployment/RuleERC2980.sol
index 488331fe..bf7510bd 100644
--- a/src/rules/validation/deployment/RuleERC2980.sol
+++ b/src/rules/validation/deployment/RuleERC2980.sol
@@ -10,24 +10,14 @@ import {RuleERC2980Base} from "../abstract/base/RuleERC2980Base.sol";
/**
* @title RuleERC2980
* @notice ERC-2980 Swiss Compliant transfer rule combining a whitelist and a frozenlist.
- * @dev
- * - Whitelist: only whitelisted addresses may receive tokens.
- * Senders do not need to be whitelisted.
- * - Frozenlist: frozen addresses are blocked from both sending and receiving.
- * Frozenlist check takes priority over the whitelist check.
+ * @dev Whitelist: only whitelisted addresses may **receive**; senders need not be listed.
+ * Frozenlist: frozen addresses may neither send nor receive, and it takes priority over the
+ * whitelist. Codes 60 (sender frozen), 61 (recipient frozen), 62 (spender frozen), 63 (recipient not
+ * whitelisted).
*
- * Access control uses {AccessControlModuleStandalone}:
- * - `WHITELIST_ADD_ROLE` — may add addresses to the whitelist.
- * - `WHITELIST_REMOVE_ROLE` — may remove addresses from the whitelist.
- * - `FROZENLIST_ADD_ROLE` — may add addresses to the frozenlist.
- * - `FROZENLIST_REMOVE_ROLE`— may remove addresses from the frozenlist.
- * - `DEFAULT_ADMIN_ROLE` — implicitly holds all roles.
- *
- * Restriction codes:
- * - 60: sender is frozen
- * - 61: recipient is frozen
- * - 62: spender is frozen
- * - 63: recipient is not whitelisted
+ * @dev Access control via {AccessControlModuleStandalone}: `WHITELIST_ADD_ROLE`,
+ * `WHITELIST_REMOVE_ROLE`, `FROZENLIST_ADD_ROLE`, `FROZENLIST_REMOVE_ROLE`, with
+ * `DEFAULT_ADMIN_ROLE` implicitly holding all of them.
*/
contract RuleERC2980 is RuleERC2980Base, AccessControlModuleStandalone {
/*//////////////////////////////////////////////////////////////
diff --git a/src/rules/validation/deployment/RuleMaxBalance.sol b/src/rules/validation/deployment/RuleMaxBalance.sol
new file mode 100644
index 00000000..8ca8243a
--- /dev/null
+++ b/src/rules/validation/deployment/RuleMaxBalance.sol
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol";
+import {AccessControlModuleStandalone} from "../../../modules/AccessControlModuleStandalone.sol";
+import {RuleMaxBalanceBase} from "../abstract/base/RuleMaxBalanceBase.sol";
+import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol";
+
+/**
+ * @title RuleMaxBalance
+ * @notice Caps how many tokens a single address may hold, with an operator-managed exemption list.
+ * @dev WARNING: pair this with a rule that admits one address per investor (`RuleWhitelist`,
+ * `RuleReceiverWhitelist` or `RuleIdentityRegistry`). The cap counts tokens per address, so a holder
+ * with several addresses can otherwise exceed it.
+ */
+contract RuleMaxBalance is AccessControlModuleStandalone, RuleMaxBalanceBase {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @param admin Address that receives the default admin role.
+ * @param balanceToken_ Token contract that exposes `balanceOf` (must be a contract).
+ * @param maxBalance_ Initial maximum balance per non-exempt address.
+ */
+ constructor(address admin, address balanceToken_, uint256 maxBalance_)
+ AccessControlModuleStandalone(admin)
+ RuleMaxBalanceBase(balanceToken_, maxBalance_)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(AccessControlEnumerable, RuleTransferValidation)
+ returns (bool)
+ {
+ return AccessControlEnumerable.supportsInterface(interfaceId)
+ || RuleTransferValidation.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts cap, token and exemption management to MAX_BALANCE_ROLE.
+ */
+ function _authorizeMaxBalanceManager() internal view virtual override onlyRole(MAX_BALANCE_ROLE) {}
+}
diff --git a/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol b/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol
new file mode 100644
index 00000000..3465049b
--- /dev/null
+++ b/src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
+import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol";
+import {RuleTransferValidation} from "../abstract/core/RuleTransferValidation.sol";
+import {RuleMaxBalanceBase} from "../abstract/base/RuleMaxBalanceBase.sol";
+
+/**
+ * @title RuleMaxBalanceOwnable2Step
+ * @notice Ownable2Step variant of RuleMaxBalance.
+ * @dev WARNING: pair this with a rule that admits one address per investor. The cap counts tokens per
+ * address, so a holder with several addresses can otherwise exceed it.
+ */
+contract RuleMaxBalanceOwnable2Step is RuleMaxBalanceBase, Ownable2Step, Ownable2StepERC165Module {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the rule, sets the owner, the observed token and the initial cap.
+ * @param owner Contract owner.
+ * @param balanceToken_ Token contract that exposes `balanceOf` (must be a contract).
+ * @param maxBalance_ Initial maximum balance per non-exempt address.
+ */
+ constructor(address owner, address balanceToken_, uint256 maxBalance_)
+ RuleMaxBalanceBase(balanceToken_, maxBalance_)
+ Ownable(owner)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(RuleTransferValidation, Ownable2StepERC165Module)
+ returns (bool)
+ {
+ return Ownable2StepERC165Module.supportsInterface(interfaceId)
+ || RuleTransferValidation.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts cap, token and exemption management to the contract owner.
+ */
+ function _authorizeMaxBalanceManager() internal view virtual override onlyOwner {}
+}
diff --git a/src/rules/validation/deployment/RuleReceiverWhitelist.sol b/src/rules/validation/deployment/RuleReceiverWhitelist.sol
new file mode 100644
index 00000000..bb59bf15
--- /dev/null
+++ b/src/rules/validation/deployment/RuleReceiverWhitelist.sol
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {AccessControlEnumerable} from "@openzeppelin/contracts/access/extensions/AccessControlEnumerable.sol";
+import {Context} from "@openzeppelin/contracts/utils/Context.sol";
+import {AccessControlModuleStandalone} from "../../../modules/AccessControlModuleStandalone.sol";
+import {RuleReceiverWhitelistBase} from "../abstract/base/RuleReceiverWhitelistBase.sol";
+import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol";
+
+/**
+ * @title RuleReceiverWhitelist
+ * @notice AccessControlEnumerable deployment variant of receiver whitelist rule.
+ */
+contract RuleReceiverWhitelist is RuleReceiverWhitelistBase, AccessControlModuleStandalone {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the rule, sets the admin and the meta-transaction forwarder.
+ * @param admin Address that receives the default admin role.
+ * @param forwarderIrrevocable Address of the ERC-2771 forwarder for meta-transactions.
+ */
+ constructor(address admin, address forwarderIrrevocable)
+ RuleReceiverWhitelistBase(forwarderIrrevocable)
+ AccessControlModuleStandalone(admin)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(AccessControlEnumerable, RuleReceiverWhitelistBase)
+ returns (bool)
+ {
+ return AccessControlEnumerable.supportsInterface(interfaceId)
+ || RuleReceiverWhitelistBase.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts adding addresses to the receiver whitelist to holders of ADDRESS_LIST_ADD_ROLE.
+ */
+ function _authorizeAddressListAdd() internal view virtual override onlyRole(ADDRESS_LIST_ADD_ROLE) {}
+
+ /**
+ * @notice Restricts removing addresses from the receiver whitelist to holders of ADDRESS_LIST_REMOVE_ROLE.
+ */
+ function _authorizeAddressListRemove() internal view virtual override onlyRole(ADDRESS_LIST_REMOVE_ROLE) {}
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns the message sender, accounting for meta-transaction (ERC-2771) context.
+ * @return sender The address of the message sender.
+ */
+ function _msgSender() internal view virtual override(Context, RuleAddressSet) returns (address sender) {
+ return super._msgSender();
+ }
+
+ /**
+ * @notice Returns the message calldata, accounting for meta-transaction (ERC-2771) context.
+ * @return The message calldata.
+ */
+ function _msgData() internal view virtual override(Context, RuleAddressSet) returns (bytes calldata) {
+ return super._msgData();
+ }
+
+ /**
+ * @notice Returns the length of the context suffix appended by the forwarder.
+ * @return The context suffix length in bytes.
+ */
+ function _contextSuffixLength() internal view virtual override(Context, RuleAddressSet) returns (uint256) {
+ return super._contextSuffixLength();
+ }
+}
diff --git a/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol b/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol
new file mode 100644
index 00000000..2feed413
--- /dev/null
+++ b/src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
+import {Context} from "@openzeppelin/contracts/utils/Context.sol";
+import {Ownable2StepERC165Module} from "../../../modules/Ownable2StepERC165Module.sol";
+import {RuleReceiverWhitelistBase} from "../abstract/base/RuleReceiverWhitelistBase.sol";
+import {RuleAddressSet} from "../abstract/RuleAddressSet/RuleAddressSet.sol";
+
+/**
+ * @title RuleReceiverWhitelistOwnable2Step
+ * @notice Ownable2Step deployment variant of receiver whitelist rule.
+ */
+contract RuleReceiverWhitelistOwnable2Step is RuleReceiverWhitelistBase, Ownable2Step, Ownable2StepERC165Module {
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Deploys the rule, sets the owner and the meta-transaction forwarder.
+ * @param owner Contract owner.
+ * @param forwarderIrrevocable Address of the ERC-2771 forwarder for meta-transactions.
+ */
+ constructor(address owner, address forwarderIrrevocable)
+ RuleReceiverWhitelistBase(forwarderIrrevocable)
+ Ownable(owner)
+ {}
+
+ /*//////////////////////////////////////////////////////////////
+ PUBLIC FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Indicates whether this contract supports a given interface.
+ * @param interfaceId The interface identifier, as specified in ERC-165.
+ * @return True if the interface is supported.
+ */
+ function supportsInterface(bytes4 interfaceId)
+ public
+ view
+ virtual
+ override(RuleReceiverWhitelistBase, Ownable2StepERC165Module)
+ returns (bool)
+ {
+ return Ownable2StepERC165Module.supportsInterface(interfaceId)
+ || RuleReceiverWhitelistBase.supportsInterface(interfaceId);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Restricts adding addresses to the receiver whitelist to the contract owner.
+ */
+ function _authorizeAddressListAdd() internal view virtual override onlyOwner {}
+
+ /**
+ * @notice Restricts removing addresses from the receiver whitelist to the contract owner.
+ */
+ function _authorizeAddressListRemove() internal view virtual override onlyOwner {}
+
+ /*//////////////////////////////////////////////////////////////
+ INTERNAL FUNCTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Returns the message sender, accounting for meta-transaction (ERC-2771) context.
+ * @return sender The address of the message sender.
+ */
+ function _msgSender() internal view virtual override(Context, RuleAddressSet) returns (address sender) {
+ return super._msgSender();
+ }
+
+ /**
+ * @notice Returns the message calldata, accounting for meta-transaction (ERC-2771) context.
+ * @return The message calldata.
+ */
+ function _msgData() internal view virtual override(Context, RuleAddressSet) returns (bytes calldata) {
+ return super._msgData();
+ }
+
+ /**
+ * @notice Returns the length of the context suffix appended by the forwarder.
+ * @return The context suffix length in bytes.
+ */
+ function _contextSuffixLength() internal view virtual override(Context, RuleAddressSet) returns (uint256) {
+ return super._contextSuffixLength();
+ }
+}
diff --git a/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol b/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
index 92a25ca6..1e686f52 100644
--- a/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithBlacklist.t.sol
@@ -7,17 +7,52 @@ import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {DeployCMTATWithBlacklist} from "script/DeployCMTATWithBlacklist.s.sol";
contract DeployCMTATWithBlacklistTest is Test {
- function testDeployCMTATWithBlacklist() public {
- DeployCMTATWithBlacklist script = new DeployCMTATWithBlacklist();
- (CMTATStandardStandalone token, RuleBlacklist rule) = _deploy(script);
+ address constant ADMIN = address(1);
+ address constant INVESTOR = address(2);
+ DeployCMTATWithBlacklist script;
+ CMTATStandardStandalone token;
+ RuleBlacklist rule;
+
+ function setUp() public {
+ script = new DeployCMTATWithBlacklist();
+ // The script contract is the acting deployer: a direct deploy() call is not a broadcast, so
+ // it makes the wiring calls itself.
+ (token, rule) = script.deploy(ADMIN, address(script), address(0));
+ }
+
+ function testBindsTheRuleToTheToken() public view {
assertEq(address(token.ruleEngine()), address(rule));
}
- function _deploy(DeployCMTATWithBlacklist script)
- internal
- returns (CMTATStandardStandalone token, RuleBlacklist rule)
- {
- (token, rule) = script.deploy(address(1), address(0));
+ /// The hand-over is the security-critical step and used to be untested (CLAUDE_ANALYSIS_SCRIPT.md S-10).
+ function testHandsAdminRoleToAdmin() public view {
+ assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), ADMIN));
+ }
+
+ function testDeployerRetainsNoAdminRole() public view {
+ assertFalse(token.hasRole(token.DEFAULT_ADMIN_ROLE(), address(script)));
+ }
+
+ function testRuleAdminIsAdmin() public view {
+ assertTrue(rule.hasRole(rule.DEFAULT_ADMIN_ROLE(), ADMIN));
+ }
+
+ /// A blacklist leaves mint open, so the token is issuable straight out of the script.
+ function testTokenCanBeMinted() public {
+ vm.prank(ADMIN);
+ token.mint(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 100);
+ }
+
+ function testBlacklistedAddressIsBlocked() public {
+ vm.prank(ADMIN);
+ token.mint(INVESTOR, 100);
+ vm.prank(ADMIN);
+ rule.addAddress(INVESTOR);
+
+ vm.prank(INVESTOR);
+ vm.expectRevert();
+ token.transfer(ADMIN, 1);
}
}
diff --git a/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol b/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
index e269aa01..c7c94ceb 100644
--- a/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithBlacklistAndSanctionsList.t.sol
@@ -49,8 +49,10 @@ contract DeployCMTATWithBlacklistAndSanctionsListTest is
sanctionOracle = new SanctionListOracle();
DeployCMTATWithBlacklistAndSanctionsList script = new DeployCMTATWithBlacklistAndSanctionsList();
+ // The script contract is the acting deployer here: a direct deploy() call is not a broadcast,
+ // so it makes the wiring calls itself.
(token, ruleEngine, ruleBlacklist, ruleSanctionsList) =
- script.deploy(ADMIN, address(0), ISanctionsList(address(sanctionOracle)));
+ script.deploy(ADMIN, address(script), address(0), ISanctionsList(address(sanctionOracle)));
// Mint initial balances before any restrictions are applied.
vm.prank(ADMIN);
diff --git a/test/DeploymentScripts/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.t.sol b/test/DeploymentScripts/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.t.sol
new file mode 100644
index 00000000..e2bd85c5
--- /dev/null
+++ b/test/DeploymentScripts/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.t.sol
@@ -0,0 +1,288 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {CMTATStandardStandalone} from "CMTAT/deployment/CMTATStandardStandalone.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
+import {RuleMaxTotalSupply} from "src/rules/validation/deployment/RuleMaxTotalSupply.sol";
+import {RuleSanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
+import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
+import {
+ RuleBlacklistInvariantStorage
+} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleBlacklistInvariantStorage.sol";
+import {
+ RuleMaxTotalSupplyInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol";
+import {
+ RuleSanctionsListInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol";
+import {SanctionListOracle} from "src/mocks/SanctionListOracle.sol";
+import {
+ DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply
+} from "script/DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply.s.sol";
+
+/**
+ * @title DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupplyTest
+ * @notice Verifies the three-rule deployment script end to end: wiring, ownership hand-over, each
+ * rule enforcing on its own, and the three of them composed.
+ * @dev The composition is the part a single-rule test cannot cover. Three rules in one engine raise
+ * questions none of them raises alone: which restriction code a rejected transfer reports when
+ * more than one rule objects, whether an address rule can block a mint that the supply cap would
+ * have allowed, and whether the supply cap leaves ordinary transfers alone.
+ */
+contract DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupplyTest is
+ Test,
+ RuleBlacklistInvariantStorage,
+ RuleSanctionsListInvariantStorage,
+ RuleMaxTotalSupplyInvariantStorage
+{
+ address private constant ADMIN = address(1);
+ address private constant ADDRESS1 = address(5);
+ address private constant ADDRESS2 = address(6);
+ address private constant ADDRESS3 = address(7);
+ address private constant ATTACKER = address(8);
+
+ uint8 private constant TRANSFER_OK = 0;
+ uint256 private constant MAX_SUPPLY = 1000;
+ uint256 private constant INITIAL_BALANCE = 100;
+
+ CMTATStandardStandalone private token;
+ RuleEngine private ruleEngine;
+ RuleBlacklist private ruleBlacklist;
+ RuleSanctionsList private ruleSanctionsList;
+ RuleMaxTotalSupply private ruleMaxTotalSupply;
+ SanctionListOracle private oracle;
+
+ function setUp() public {
+ oracle = new SanctionListOracle();
+
+ DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply script =
+ new DeployCMTATWithBlacklistSanctionsListAndMaxTotalSupply();
+ (token, ruleEngine, ruleBlacklist, ruleSanctionsList, ruleMaxTotalSupply) =
+ script.deploy(ADMIN, address(script), address(0), ISanctionsList(address(oracle)), MAX_SUPPLY);
+
+ vm.startPrank(ADMIN);
+ token.mint(ADDRESS1, INITIAL_BALANCE);
+ token.mint(ADDRESS2, INITIAL_BALANCE);
+ vm.stopPrank();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DEPLOYMENT / WIRING
+ //////////////////////////////////////////////////////////////*/
+
+ function testRuleEngineIsSetOnTheToken() public view {
+ assertEq(address(token.ruleEngine()), address(ruleEngine));
+ }
+
+ function testAllThreeRulesAreRegisteredInOrder() public view {
+ assertEq(ruleEngine.rulesCount(), 3);
+ assertEq(ruleEngine.rule(0), address(ruleBlacklist));
+ assertEq(ruleEngine.rule(1), address(ruleSanctionsList));
+ assertEq(ruleEngine.rule(2), address(ruleMaxTotalSupply));
+ }
+
+ function testAdminOwnsEverythingAndTheDeployerOwnsNothing() public view {
+ assertTrue(token.hasRole(bytes32(0), ADMIN));
+ assertTrue(ruleEngine.hasRole(bytes32(0), ADMIN));
+ assertTrue(ruleBlacklist.hasRole(bytes32(0), ADMIN));
+ assertTrue(ruleSanctionsList.hasRole(bytes32(0), ADMIN));
+ assertTrue(ruleMaxTotalSupply.hasRole(bytes32(0), ADMIN));
+ // The deployment key must not retain standing rights.
+ assertFalse(token.hasRole(bytes32(0), address(this)));
+ assertFalse(ruleEngine.hasRole(bytes32(0), address(this)));
+ }
+
+ function testSupplyRuleIsBoundToThisTokenWithTheGivenCap() public view {
+ assertEq(address(ruleMaxTotalSupply.tokenContract()), address(token));
+ assertEq(ruleMaxTotalSupply.maxTotalSupply(), MAX_SUPPLY);
+ }
+
+ function testSanctionsOracleIsConfigured() public view {
+ assertEq(address(ruleSanctionsList.sanctionsList()), address(oracle));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EACH RULE ENFORCES ON ITS OWN
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferSucceedsWhenNothingApplies() public {
+ vm.prank(ADDRESS1);
+ token.transfer(ADDRESS2, 10);
+ assertEq(token.balanceOf(ADDRESS2), INITIAL_BALANCE + 10);
+ }
+
+ function testBlacklistedSenderIsBlocked() public {
+ vm.prank(ADMIN);
+ ruleBlacklist.addAddress(ADDRESS1);
+
+ assertEq(ruleEngine.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_BLACKLISTED);
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ token.transfer(ADDRESS2, 10);
+ }
+
+ function testBlacklistedRecipientIsBlocked() public {
+ vm.prank(ADMIN);
+ ruleBlacklist.addAddress(ADDRESS2);
+
+ assertEq(ruleEngine.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_TO_IS_BLACKLISTED);
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ token.transfer(ADDRESS2, 10);
+ }
+
+ function testSanctionedSenderIsBlocked() public {
+ oracle.addToSanctionsList(ADDRESS1);
+
+ assertEq(ruleEngine.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_SANCTIONED);
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ token.transfer(ADDRESS2, 10);
+ }
+
+ function testSanctionedRecipientIsBlocked() public {
+ oracle.addToSanctionsList(ADDRESS2);
+
+ assertEq(ruleEngine.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_TO_IS_SANCTIONED);
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ token.transfer(ADDRESS2, 10);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE SUPPLY CAP
+ //////////////////////////////////////////////////////////////*/
+
+ function testMintUpToTheCapSucceeds() public {
+ uint256 headroom = MAX_SUPPLY - token.totalSupply();
+ vm.prank(ADMIN);
+ token.mint(ADDRESS3, headroom);
+ assertEq(token.totalSupply(), MAX_SUPPLY);
+ }
+
+ function testMintPastTheCapIsBlocked() public {
+ uint256 headroom = MAX_SUPPLY - token.totalSupply();
+
+ assertEq(
+ ruleEngine.detectTransferRestrictionFrom(ADMIN, address(0), ADDRESS3, headroom + 1),
+ CODE_MAX_TOTAL_SUPPLY_EXCEEDED
+ );
+ vm.prank(ADMIN);
+ vm.expectRevert();
+ token.mint(ADDRESS3, headroom + 1);
+ assertEq(token.totalSupply(), INITIAL_BALANCE * 2, "a rejected mint must not move supply");
+ }
+
+ function testTheCapDoesNotRestrictOrdinaryTransfers() public {
+ // Fill the supply to the cap, then move tokens around: transfers do not change totalSupply.
+ vm.startPrank(ADMIN);
+ token.mint(ADDRESS3, MAX_SUPPLY - token.totalSupply());
+ vm.stopPrank();
+ assertEq(token.totalSupply(), MAX_SUPPLY);
+
+ vm.prank(ADDRESS1);
+ token.transfer(ADDRESS2, 50);
+ assertEq(token.balanceOf(ADDRESS2), INITIAL_BALANCE + 50);
+ }
+
+ function testBurningFreesHeadroomForANewMint() public {
+ vm.startPrank(ADMIN);
+ token.mint(ADDRESS3, MAX_SUPPLY - token.totalSupply());
+ vm.stopPrank();
+
+ // At the cap, a further mint is rejected...
+ vm.prank(ADMIN);
+ vm.expectRevert();
+ token.mint(ADDRESS3, 1);
+
+ // ...but a burn lowers totalSupply, and the same mint then succeeds.
+ vm.prank(ADMIN);
+ token.burn(ADDRESS1, 10);
+ vm.prank(ADMIN);
+ token.mint(ADDRESS3, 10);
+ assertEq(token.totalSupply(), MAX_SUPPLY);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ COMPOSITION — what only the three together can show
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The engine reports the FIRST non-zero code, so registration order decides which reason
+ * a transfer rejected by several rules is attributed to.
+ */
+ function testTheFirstRegisteredRuleWinsTheRestrictionCode() public {
+ vm.prank(ADMIN);
+ ruleBlacklist.addAddress(ADDRESS1);
+ oracle.addToSanctionsList(ADDRESS1);
+
+ // Both object; the blacklist is registered first, so its code is the one surfaced.
+ assertEq(ruleEngine.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_BLACKLISTED);
+ }
+
+ /**
+ * @notice An address rule can reject a mint the supply cap would have allowed — the rules are
+ * independent gates, not alternatives.
+ */
+ function testAnAddressRuleBlocksAMintThatIsWithinTheCap() public {
+ vm.prank(ADMIN);
+ ruleBlacklist.addAddress(ADDRESS3);
+
+ // Well within the cap, but the recipient is blacklisted.
+ assertEq(
+ ruleEngine.detectTransferRestrictionFrom(ADMIN, address(0), ADDRESS3, 1), CODE_ADDRESS_TO_IS_BLACKLISTED
+ );
+ vm.prank(ADMIN);
+ vm.expectRevert();
+ token.mint(ADDRESS3, 1);
+ }
+
+ /**
+ * @notice And the converse: a clean address is still stopped by the cap.
+ */
+ function testACleanAddressIsStillStoppedByTheCap() public {
+ uint256 headroom = MAX_SUPPLY - token.totalSupply();
+ assertFalse(ruleBlacklist.isAddressListed(ADDRESS3));
+ assertFalse(oracle.isSanctioned(ADDRESS3));
+
+ assertEq(
+ ruleEngine.detectTransferRestrictionFrom(ADMIN, address(0), ADDRESS3, headroom + 1),
+ CODE_MAX_TOTAL_SUPPLY_EXCEEDED
+ );
+ }
+
+ /**
+ * @notice Removing a restriction re-opens the path, through the engine, without redeployment.
+ */
+ function testLiftingARestrictionRestoresTransfers() public {
+ vm.prank(ADMIN);
+ ruleBlacklist.addAddress(ADDRESS1);
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ token.transfer(ADDRESS2, 10);
+
+ vm.prank(ADMIN);
+ ruleBlacklist.removeAddress(ADDRESS1);
+
+ vm.prank(ADDRESS1);
+ token.transfer(ADDRESS2, 10);
+ assertEq(token.balanceOf(ADDRESS2), INITIAL_BALANCE + 10);
+ }
+
+ /**
+ * @notice Every rule is reachable through the engine's aggregated message lookup.
+ */
+ function testEachRulesMessageIsResolvableThroughTheEngine() public view {
+ assertEq(
+ ruleEngine.messageForTransferRestriction(CODE_ADDRESS_FROM_IS_BLACKLISTED), TEXT_ADDRESS_FROM_IS_BLACKLISTED
+ );
+ assertEq(
+ ruleEngine.messageForTransferRestriction(CODE_ADDRESS_FROM_IS_SANCTIONED), TEXT_ADDRESS_FROM_IS_SANCTIONED
+ );
+ assertEq(
+ ruleEngine.messageForTransferRestriction(CODE_MAX_TOTAL_SUPPLY_EXCEEDED), TEXT_MAX_TOTAL_SUPPLY_EXCEEDED
+ );
+ }
+}
diff --git a/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol b/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
index 490e8d04..e9f6adf2 100644
--- a/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
+++ b/test/DeploymentScripts/DeployCMTATWithWhitelist.t.sol
@@ -7,16 +7,78 @@ import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
import {DeployCMTATWithWhitelist} from "script/DeployCMTATWithWhitelist.s.sol";
contract DeployCMTATWithWhitelistTest is Test {
- function testDeployCMTATWithWhitelist() public {
- DeployCMTATWithWhitelist script = new DeployCMTATWithWhitelist();
- (CMTATStandardStandalone token, RuleWhitelist rule) = _deploy(script);
+ address constant ADMIN = address(1);
+ address constant INVESTOR = address(2);
+ address constant OTHER = address(3);
+
+ /// RuleWhitelistInvariantStorage.CODE_MINT_NOT_ALLOWED
+ uint8 constant CODE_MINT_NOT_ALLOWED = 24;
+
+ DeployCMTATWithWhitelist script;
+ CMTATStandardStandalone token;
+ RuleWhitelist rule;
+
+ function setUp() public {
+ script = new DeployCMTATWithWhitelist();
+ // The script contract is the acting deployer: a direct deploy() call is not a broadcast, so
+ // it makes the wiring calls itself.
+ (token, rule) = script.deploy(ADMIN, address(script), address(0), false, true);
+ }
+
+ function testBindsTheRuleToTheToken() public view {
assertEq(address(token.ruleEngine()), address(rule));
}
- function _deploy(DeployCMTATWithWhitelist script)
- internal
- returns (CMTATStandardStandalone token, RuleWhitelist rule)
- {
- (token, rule) = script.deploy(address(1), address(0), false);
+ /// The hand-over is the security-critical step and used to be untested (CLAUDE_ANALYSIS_SCRIPT.md S-10).
+ function testHandsAdminRoleToAdmin() public view {
+ assertTrue(token.hasRole(token.DEFAULT_ADMIN_ROLE(), ADMIN));
+ }
+
+ function testDeployerRetainsNoAdminRole() public view {
+ assertFalse(token.hasRole(token.DEFAULT_ADMIN_ROLE(), address(script)));
+ }
+
+ function testRuleAdminIsAdmin() public view {
+ assertTrue(rule.hasRole(rule.DEFAULT_ADMIN_ROLE(), ADMIN));
+ }
+
+ /**
+ * The script used to hard-code `allowMintBurn = false`, which produced a token that could not be
+ * issued at all: mint was rejected with code 24 even to a whitelisted investor
+ * (CLAUDE_ANALYSIS_SCRIPT.md S-3). run() now passes true.
+ */
+ function testTokenCanBeMintedToAWhitelistedInvestor() public {
+ vm.prank(ADMIN);
+ rule.addAddress(INVESTOR);
+
+ vm.prank(ADMIN);
+ token.mint(INVESTOR, 100);
+
+ assertEq(token.balanceOf(INVESTOR), 100);
+ }
+
+ /// Pins the failure mode the old default caused, so a regression is legible rather than puzzling.
+ function testMintIsRejectedWhenMintBurnIsNotAllowed() public {
+ (CMTATStandardStandalone t2, RuleWhitelist r2) = script.deploy(ADMIN, address(script), address(0), false, false);
+
+ vm.prank(ADMIN);
+ r2.addAddress(INVESTOR);
+
+ assertEq(r2.detectTransferRestriction(address(0), INVESTOR, 100), CODE_MINT_NOT_ALLOWED);
+
+ vm.prank(ADMIN);
+ vm.expectRevert();
+ t2.mint(INVESTOR, 100);
+ }
+
+ function testTransferToNonWhitelistedAddressIsBlocked() public {
+ vm.prank(ADMIN);
+ rule.addAddress(INVESTOR);
+ vm.prank(ADMIN);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ vm.expectRevert();
+ token.transfer(OTHER, 1);
}
}
diff --git a/test/ERC3643Compliance/ERC3643RuleEngineWhitelist.t.sol b/test/ERC3643Compliance/ERC3643RuleEngineWhitelist.t.sol
new file mode 100644
index 00000000..5ad8399d
--- /dev/null
+++ b/test/ERC3643Compliance/ERC3643RuleEngineWhitelist.t.sol
@@ -0,0 +1,286 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {ERC3643TokenMock, IERC3643ComplianceForToken} from "src/mocks/ERC3643TokenMock.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {
+ IdentityRegistryWhitelistInvariantStorage
+} from "src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol";
+import {IIdentityRegistryERC3643} from "src/registry/interfaces/IIdentityRegistryERC3643.sol";
+import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+
+/**
+ * @title Integration test: ERC-3643 token -> RuleEngine (as compliance) -> RuleWhitelist
+ * @notice The RuleEngine occupies the token's **compliance** slot (`setCompliance`), not its
+ * identity-registry slot, so a CMTAT rule library can enforce an ERC-3643 token's transfer
+ * policy. The identity-registry slot is filled separately by `IdentityRegistryWhitelist`,
+ * which lets each test show which of the two slots is doing the blocking.
+ *
+ * Wiring, transcribed from `Token.setCompliance` (`Token.sol:515-522`): the token calls
+ * `bindToken(address(this))` on the compliance contract **itself**, so the engine needs
+ * `setTokenSelfBindingApproval(token, true)` beforehand. That path exists in
+ * `ERC3643ComplianceExtendedModule._authorizeComplianceBindingChange` specifically for
+ * ERC-3643 compatibility.
+ */
+contract ERC3643RuleEngineWhitelist is Test, HelperContract, IdentityRegistryWhitelistInvariantStorage {
+ address constant AGENT = address(10);
+ address constant INVESTOR = address(11);
+ address constant INVESTOR2 = address(12);
+ address constant OUTSIDER = address(14);
+
+ IdentityRegistryWhitelist private registry;
+ RuleWhitelist private whitelistRule;
+ RuleEngine private engine;
+ ERC3643TokenMock private token;
+
+ function setUp() public {
+ // ---- identity registry slot -------------------------------------------------
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry = new IdentityRegistryWhitelist(DEFAULT_ADMIN_ADDRESS);
+
+ // ---- compliance slot: RuleEngine + RuleWhitelist -----------------------------
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule = new RuleWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, false, false);
+
+ // The engine is deployed before the token exists, so it is bound afterwards.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ engine = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, ZERO_ADDRESS);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ engine.addRule(whitelistRule);
+
+ token = new ERC3643TokenMock(IIdentityRegistryERC3643(address(registry)), AGENT);
+
+ // Allow the token's self-binding call inside setCompliance.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ engine.setTokenSelfBindingApproval(address(token), true);
+ token.setCompliance(IERC3643ComplianceForToken(address(engine)));
+
+ // ---- populate both slots ----------------------------------------------------
+ bytes32 registrarRole = registry.IDENTITY_REGISTRAR_ROLE();
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registrarRole, AGENT);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registrarRole, address(token));
+
+ // Everyone below is identity-verified, so any rejection comes from the RuleEngine.
+ vm.startPrank(AGENT);
+ registry.registerIdentity(INVESTOR, address(0), 0);
+ registry.registerIdentity(INVESTOR2, address(0), 0);
+ registry.registerIdentity(OUTSIDER, address(0), 0);
+ vm.stopPrank();
+
+ // ...but only INVESTOR and INVESTOR2 are on the compliance whitelist.
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.addAddress(INVESTOR);
+ whitelistRule.addAddress(INVESTOR2);
+ vm.stopPrank();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ WIRING
+ //////////////////////////////////////////////////////////////*/
+
+ function testWiring() public view {
+ assertEq(address(token.compliance()), address(engine), "engine is the compliance contract");
+ assertTrue(engine.isTokenBound(address(token)), "token bound to the engine");
+ assertEq(address(token.identityRegistry()), address(registry), "registry is separate");
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MINT
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `mint` asks the engine `canTransfer(address(0), to, amount)`, so the whitelist rule
+ * must allow mint. `allowMint` is false here, so minting is rejected by the RULE even
+ * though the recipient is identity-verified.
+ */
+ function testMint_RejectedWhenTheRuleForbidsMinting() public {
+ vm.prank(AGENT);
+ vm.expectRevert("Compliance not followed");
+ token.mint(INVESTOR, 100);
+ }
+
+ function testMint_AllowedOnceTheRulePermitsMinting() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.setAllowMint(true);
+
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 100);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ function _mint(address to, uint256 amount) private {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.setAllowMint(true);
+ vm.prank(AGENT);
+ token.mint(to, amount);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.setAllowMint(false);
+ }
+
+ function testTransfer_BetweenWhitelistedHolders() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ assertTrue(token.transfer(INVESTOR2, 40));
+ assertEq(token.balanceOf(INVESTOR2), 40);
+ }
+
+ /**
+ * @notice The decisive case: OUTSIDER is identity-verified, so the registry lets the transfer
+ * through, and it is the RuleEngine's whitelist rule that rejects it.
+ */
+ function testTransfer_RejectedByTheRuleEvenWhenIdentityVerified() public {
+ _mint(INVESTOR, 100);
+ assertTrue(registry.isVerified(OUTSIDER), "identity slot would allow it");
+ assertFalse(whitelistRule.isAddressListed(OUTSIDER), "compliance slot will not");
+
+ vm.prank(INVESTOR);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transfer(OUTSIDER, 40);
+ }
+
+ /**
+ * @notice And the mirror image: whitelisted by the rule but not identity-verified. Confirms the
+ * two slots are enforced independently rather than one masking the other.
+ */
+ function testTransfer_RejectedByTheRegistryEvenWhenRuleWhitelisted() public {
+ _mint(INVESTOR, 100);
+
+ address ruleOnly = address(15);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.addAddress(ruleOnly);
+ assertFalse(registry.isVerified(ruleOnly));
+
+ vm.prank(INVESTOR);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transfer(ruleOnly, 40);
+ }
+
+ function testTransferFrom_GoesThroughTheEngineToo() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(OUTSIDER);
+ assertTrue(token.transferFrom(INVESTOR, INVESTOR2, 30));
+
+ vm.prank(OUTSIDER);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transferFrom(INVESTOR, OUTSIDER, 30);
+ }
+
+ /**
+ * @notice Removing an address from the rule blocks it immediately — the engine is consulted on
+ * every transfer, not cached.
+ */
+ function testTransfer_BlockedAfterTheRecipientIsRemovedFromTheRule() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.removeAddress(INVESTOR2);
+
+ vm.prank(INVESTOR);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transfer(INVESTOR2, 40);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FORCED TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `forcedTransfer` never calls `canTransfer` — it only notifies `transferred`
+ * afterwards. Because a RuleEngine **reverts** in `transferred` when a rule rejects,
+ * the agent still cannot force tokens onto a non-whitelisted address. The revert comes
+ * from the rule, not from the token's own "Transfer not possible" branch.
+ */
+ function testForcedTransfer_StillBlockedByTheRuleViaTransferred() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleWhitelist_InvalidTransfer.selector,
+ address(whitelistRule),
+ INVESTOR,
+ OUTSIDER,
+ 40,
+ CODE_ADDRESS_TO_NOT_WHITELISTED
+ )
+ );
+ token.forcedTransfer(INVESTOR, OUTSIDER, 40);
+
+ assertEq(token.balanceOf(OUTSIDER), 0);
+ }
+
+ function testForcedTransfer_AllowedToAWhitelistedHolder() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ assertTrue(token.forcedTransfer(INVESTOR, INVESTOR2, 60));
+ assertEq(token.balanceOf(INVESTOR2), 60);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ BURN
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `burn` calls only `destroyed`, never `canTransfer`. With `allowBurn` false the rule
+ * rejects it through that notification.
+ */
+ function testBurn_BlockedByTheRuleWhenBurningIsNotAllowed() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleWhitelist_InvalidTransfer.selector,
+ address(whitelistRule),
+ INVESTOR,
+ ZERO_ADDRESS,
+ 100,
+ CODE_BURN_NOT_ALLOWED
+ )
+ );
+ token.burn(INVESTOR, 100);
+ }
+
+ function testBurn_AllowedOnceTheRulePermitsBurning() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ whitelistRule.setAllowBurn(true);
+
+ vm.prank(AGENT);
+ token.burn(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 0);
+ assertEq(token.totalSupply(), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ENGINE BOOKKEEPING
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The engine only accepts `transferred` / `created` / `destroyed` from a bound token,
+ * so an arbitrary caller cannot drive rule state through it.
+ */
+ function testEngineRejectsCallbacksFromAnUnboundCaller() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert();
+ engine.transferred(INVESTOR, INVESTOR2, 1);
+ }
+}
diff --git a/test/ERC3643Real/ERC3643RealTokenRuleEngine.t.sol b/test/ERC3643Real/ERC3643RealTokenRuleEngine.t.sol
new file mode 100644
index 00000000..2b849791
--- /dev/null
+++ b/test/ERC3643Real/ERC3643RealTokenRuleEngine.t.sol
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity 0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+import {ComplianceNotFollowed, Token, TransferNotPossible} from "ERC3643/token/Token.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+import {
+ RuleWhitelistInvariantStorage
+} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleWhitelistInvariantStorage.sol";
+
+/**
+ * @title Integration test against the REAL vendored ERC-3643 token
+ * @notice Everything else in this repository exercises `ERC3643TokenMock`, whose call sequences are
+ * transcribed from `Token.sol`. This suite deploys the genuine `Token` from
+ * `lib/ERC-3643/` (4.2.0-beta1) and drives the same two slots through it:
+ *
+ * real ERC-3643 Token ── compliance slot ──▶ RuleEngine ──▶ RuleWhitelist
+ * └─ identity slot ────▶ IdentityRegistryWhitelist
+ *
+ * Its value is precisely that nothing here is transcribed: if upstream changes when the
+ * token consults compliance or the registry, these tests break and the mock's fidelity
+ * claim is re-checked for free.
+ *
+ * @dev Built by a dedicated profile because `Token.sol` pins `pragma solidity 0.8.30` exactly,
+ * which cannot share a compilation unit with the project's 0.8.34. This file therefore also
+ * pins 0.8.30 (our own contracts are `^0.8.20`, so they compile at it happily). Run with:
+ *
+ * FOUNDRY_PROFILE=erc3643 forge test
+ *
+ * `test/ERC3643Real/**` is in the default profile's `skip` list so the ordinary `forge test`
+ * is unaffected.
+ */
+contract ERC3643RealTokenRuleEngine is Test, RuleWhitelistInvariantStorage {
+ address constant ADMIN = address(1);
+ address constant AGENT = address(10);
+ address constant INVESTOR = address(11);
+ address constant INVESTOR2 = address(12);
+ address constant OUTSIDER = address(14);
+
+ IdentityRegistryWhitelist private registry;
+ RuleWhitelist private whitelistRule;
+ RuleEngine private engine;
+ Token private token;
+
+ function setUp() public {
+ vm.startPrank(ADMIN);
+ registry = new IdentityRegistryWhitelist(ADMIN);
+ whitelistRule = new RuleWhitelist(ADMIN, address(0), false, false);
+ engine = new RuleEngine(ADMIN, address(0), address(0));
+ engine.addRule(whitelistRule);
+ vm.stopPrank();
+
+ // The real token self-binds to its compliance inside `init`/`setCompliance`.
+ token = new Token();
+ vm.prank(ADMIN);
+ engine.setTokenSelfBindingApproval(address(token), true);
+
+ token.init(address(registry), address(engine), "Real ERC-3643", "R3643", 0, address(0));
+
+ // `init` leaves the token paused and makes the deployer its owner.
+ token.addAgent(AGENT);
+ vm.prank(AGENT);
+ token.unpause();
+
+ bytes32 registrarRole = registry.IDENTITY_REGISTRAR_ROLE();
+ vm.startPrank(ADMIN);
+ registry.grantRole(registrarRole, AGENT);
+ registry.grantRole(registrarRole, address(token));
+ vm.stopPrank();
+
+ // Identity-verify everyone, so any rejection below comes from the RuleEngine.
+ vm.startPrank(AGENT);
+ registry.registerIdentity(INVESTOR, address(0), 0);
+ registry.registerIdentity(INVESTOR2, address(0), 0);
+ registry.registerIdentity(OUTSIDER, address(0), 0);
+ vm.stopPrank();
+
+ // ...but only INVESTOR and INVESTOR2 pass the compliance whitelist.
+ vm.startPrank(ADMIN);
+ whitelistRule.addAddress(INVESTOR);
+ whitelistRule.addAddress(INVESTOR2);
+ vm.stopPrank();
+ }
+
+ /**
+ * @notice Mints through the real token with the rule's mint gate open.
+ */
+ function _mint(address to, uint256 amount) private {
+ vm.prank(ADMIN);
+ whitelistRule.setAllowMint(true);
+ vm.prank(AGENT);
+ token.mint(to, amount);
+ vm.prank(ADMIN);
+ whitelistRule.setAllowMint(false);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ WIRING
+ //////////////////////////////////////////////////////////////*/
+
+ function testWiring() public view {
+ assertEq(address(token.compliance()), address(engine), "engine is the compliance contract");
+ assertEq(address(token.identityRegistry()), address(registry), "registry is the identity slot");
+ assertTrue(engine.isTokenBound(address(token)), "token bound to the engine");
+ assertEq(token.symbol(), "R3643");
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MINT
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The real `mint` requires `compliance.canTransfer(address(0), to, amount)`, so the
+ * whitelist rule's `allowMint` flag gates issuance.
+ */
+ function testMint_RejectedWhenTheRuleForbidsMinting() public {
+ vm.prank(AGENT);
+ vm.expectRevert(ComplianceNotFollowed.selector);
+ token.mint(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 0);
+ }
+
+ function testMint_AllowedOnceTheRulePermitsMinting() public {
+ _mint(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 100);
+ assertEq(token.totalSupply(), 100);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransfer_BetweenWhitelistedHolders() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ assertTrue(token.transfer(INVESTOR2, 40));
+ assertEq(token.balanceOf(INVESTOR2), 40);
+ assertEq(token.balanceOf(INVESTOR), 60);
+ }
+
+ /**
+ * @notice The decisive case: OUTSIDER passes the identity registry, so it is the RuleEngine's
+ * whitelist rule that blocks the transfer — proving the compliance slot is live on the
+ * real token, not merely wired.
+ */
+ function testTransfer_RejectedByTheRuleEvenWhenIdentityVerified() public {
+ _mint(INVESTOR, 100);
+ assertTrue(registry.isVerified(OUTSIDER), "identity slot would allow it");
+ assertFalse(whitelistRule.isAddressListed(OUTSIDER), "compliance slot will not");
+
+ // The token's own error: `canTransfer` returned false, so it never reached `transferred`.
+ vm.prank(INVESTOR);
+ vm.expectRevert(TransferNotPossible.selector);
+ token.transfer(OUTSIDER, 40);
+ assertEq(token.balanceOf(OUTSIDER), 0);
+ }
+
+ /**
+ * @notice The mirror image: on the rule's whitelist but not identity-verified. Together with
+ * the previous test this shows the two slots are enforced independently.
+ */
+ function testTransfer_RejectedByTheRegistryEvenWhenRuleWhitelisted() public {
+ _mint(INVESTOR, 100);
+
+ address ruleOnly = address(15);
+ vm.prank(ADMIN);
+ whitelistRule.addAddress(ruleOnly);
+ assertFalse(registry.isVerified(ruleOnly));
+
+ vm.prank(INVESTOR);
+ vm.expectRevert(TransferNotPossible.selector);
+ token.transfer(ruleOnly, 40);
+ assertEq(token.balanceOf(ruleOnly), 0);
+ }
+
+ function testTransfer_BlockedAfterTheRecipientIsRemovedFromTheRule() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(ADMIN);
+ whitelistRule.removeAddress(INVESTOR2);
+
+ vm.prank(INVESTOR);
+ vm.expectRevert(TransferNotPossible.selector);
+ token.transfer(INVESTOR2, 40);
+ }
+
+ function testTransferFrom_GoesThroughTheEngineToo() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ token.approve(OUTSIDER, 100);
+
+ vm.prank(OUTSIDER);
+ assertTrue(token.transferFrom(INVESTOR, INVESTOR2, 30));
+ assertEq(token.balanceOf(INVESTOR2), 30);
+
+ vm.prank(OUTSIDER);
+ vm.expectRevert(TransferNotPossible.selector);
+ token.transferFrom(INVESTOR, OUTSIDER, 30);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FORCED TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `forcedTransfer` never consults `canTransfer` — it only notifies `transferred`. A
+ * RuleEngine reverts inside that notification, so an agent still cannot force tokens
+ * onto a non-whitelisted address. Verified here against the real token rather than
+ * inferred from the mock.
+ */
+ function testForcedTransfer_StillBlockedByTheRuleViaTransferred() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleWhitelist_InvalidTransfer.selector,
+ address(whitelistRule),
+ INVESTOR,
+ OUTSIDER,
+ 40,
+ CODE_ADDRESS_TO_NOT_WHITELISTED
+ )
+ );
+ token.forcedTransfer(INVESTOR, OUTSIDER, 40);
+ assertEq(token.balanceOf(OUTSIDER), 0);
+ }
+
+ function testForcedTransfer_AllowedToAWhitelistedHolder() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ assertTrue(token.forcedTransfer(INVESTOR, INVESTOR2, 60));
+ assertEq(token.balanceOf(INVESTOR2), 60);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ BURN
+ //////////////////////////////////////////////////////////////*/
+
+ function testBurn_BlockedByTheRuleWhenBurningIsNotAllowed() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleWhitelist_InvalidTransfer.selector,
+ address(whitelistRule),
+ INVESTOR,
+ address(0),
+ 100,
+ CODE_BURN_NOT_ALLOWED
+ )
+ );
+ token.burn(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 100);
+ }
+
+ function testBurn_AllowedOnceTheRulePermitsBurning() public {
+ _mint(INVESTOR, 100);
+
+ vm.prank(ADMIN);
+ whitelistRule.setAllowBurn(true);
+
+ vm.prank(AGENT);
+ token.burn(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 0);
+ assertEq(token.totalSupply(), 0);
+ }
+}
diff --git a/test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol b/test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol
new file mode 100644
index 00000000..90acdeb9
--- /dev/null
+++ b/test/ERC3643Real/ERC3643ReceiverWhitelistParity.t.sol
@@ -0,0 +1,180 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity 0.8.30;
+
+import {Test} from "forge-std/Test.sol";
+import {Token} from "ERC3643/token/Token.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {RuleReceiverWhitelist} from "src/rules/validation/deployment/RuleReceiverWhitelist.sol";
+
+/**
+ * @title Parity test: RuleReceiverWhitelist vs ERC-3643's own eligibility rule
+ * @notice `RuleReceiverWhitelist` claims to reproduce ERC-3643's screening semantics — receiver
+ * only. This suite tests that claim the only way it can be tested honestly: run the rule
+ * in the **compliance** slot of the real vendored `Token.sol` over the *same address set*
+ * the identity registry holds, and assert the rule never changes the outcome.
+ *
+ * real ERC-3643 Token ── compliance slot ──▶ RuleEngine ──▶ RuleReceiverWhitelist
+ * └─ identity slot ────▶ IdentityRegistryWhitelist (same members)
+ *
+ * If the rule screened the sender or the spender, the token would start rejecting
+ * transfers that stock ERC-3643 accepts, and these tests would fail. That makes this a
+ * real equivalence check rather than a restatement of the rule's own logic.
+ *
+ * @dev Built by the `erc3643` profile — see `foundry.toml`. Run with:
+ * FOUNDRY_PROFILE=erc3643 forge test
+ */
+contract ERC3643ReceiverWhitelistParity is Test {
+ address constant ADMIN = address(1);
+ address constant AGENT = address(10);
+ address constant HOLDER = address(11);
+ address constant ELIGIBLE = address(12);
+ address constant OUTSIDER = address(14);
+
+ IdentityRegistryWhitelist private registry;
+ RuleReceiverWhitelist private receiverRule;
+ RuleEngine private engine;
+ Token private token;
+
+ function setUp() public {
+ vm.startPrank(ADMIN);
+ registry = new IdentityRegistryWhitelist(ADMIN);
+ receiverRule = new RuleReceiverWhitelist(ADMIN, address(0));
+ engine = new RuleEngine(ADMIN, address(0), address(0));
+ engine.addRule(receiverRule);
+ vm.stopPrank();
+
+ token = new Token();
+ vm.prank(ADMIN);
+ engine.setTokenSelfBindingApproval(address(token), true);
+ token.init(address(registry), address(engine), "Parity", "PAR", 0, address(0));
+
+ token.addAgent(AGENT);
+ vm.prank(AGENT);
+ token.unpause();
+
+ bytes32 registrarRole = registry.IDENTITY_REGISTRAR_ROLE();
+ vm.startPrank(ADMIN);
+ registry.grantRole(registrarRole, AGENT);
+ registry.grantRole(registrarRole, address(token));
+ vm.stopPrank();
+
+ // The SAME membership on both slots: HOLDER and ELIGIBLE in, OUTSIDER out.
+ vm.startPrank(AGENT);
+ registry.registerIdentity(HOLDER, address(0), 0);
+ registry.registerIdentity(ELIGIBLE, address(0), 0);
+ vm.stopPrank();
+ vm.startPrank(ADMIN);
+ receiverRule.addAddress(HOLDER);
+ receiverRule.addAddress(ELIGIBLE);
+ vm.stopPrank();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE RULE IS TRANSPARENT
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Mint to an eligible receiver: ERC-3643 allows it, so the rule must not block it.
+ * Note there is no `allowMint` flag to open — the rule gates mint on the receiver
+ * alone, exactly as ERC-3643 does.
+ */
+ function testMint_ToEligibleReceiverPassesBothSlots() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+ assertEq(token.balanceOf(HOLDER), 100);
+ }
+
+ function testTransfer_BetweenEligiblePartiesPasses() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+
+ vm.prank(HOLDER);
+ assertTrue(token.transfer(ELIGIBLE, 40));
+ assertEq(token.balanceOf(ELIGIBLE), 40);
+ }
+
+ /**
+ * @notice The load-bearing test. A de-listed holder must still be able to exit — ERC-3643
+ * screens only the receiver precisely so a lapsed investor is not trapped. A rule that
+ * screened the sender would break this while every other test still passed.
+ */
+ function testDeListedHolderCanStillExit() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+
+ // Drop the sender from BOTH slots.
+ vm.prank(AGENT);
+ registry.deleteIdentity(HOLDER);
+ vm.prank(ADMIN);
+ receiverRule.removeAddress(HOLDER);
+
+ assertFalse(registry.isVerified(HOLDER), "sender de-listed on the identity slot");
+ assertFalse(receiverRule.isAddressListed(HOLDER), "sender de-listed on the compliance slot");
+
+ // ...and the position is still movable to an eligible counterparty.
+ vm.prank(HOLDER);
+ assertTrue(token.transfer(ELIGIBLE, 100));
+ assertEq(token.balanceOf(ELIGIBLE), 100);
+ }
+
+ /**
+ * @notice `transferFrom` "works the same way" per ERC-3643: an unlisted spender is fine.
+ */
+ function testTransferFrom_UnlistedSpenderIsNotBlocked() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+
+ vm.prank(HOLDER);
+ token.approve(OUTSIDER, 100);
+ assertFalse(receiverRule.isAddressListed(OUTSIDER), "spender is not listed");
+
+ vm.prank(OUTSIDER);
+ assertTrue(token.transferFrom(HOLDER, ELIGIBLE, 30));
+ assertEq(token.balanceOf(ELIGIBLE), 30);
+ }
+
+ /**
+ * @notice `burn` bypasses eligibility in ERC-3643, so the rule must not block it either — even
+ * for a de-listed holder. The rule's burn exemption is what makes this pass; without
+ * it, `address(0)` would be an unlisted receiver and every burn would revert.
+ */
+ function testBurn_IsNotBlockedEvenForADeListedHolder() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+
+ vm.prank(AGENT);
+ registry.deleteIdentity(HOLDER);
+ vm.prank(ADMIN);
+ receiverRule.removeAddress(HOLDER);
+
+ vm.prank(AGENT);
+ token.burn(HOLDER, 100);
+ assertEq(token.balanceOf(HOLDER), 0);
+ assertEq(token.totalSupply(), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ AND IT STILL ENFORCES
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Transparency must not mean inertness: with the rule's list narrower than the
+ * registry's, the rule blocks. Proves the previous tests pass because the semantics
+ * agree, not because the rule is never consulted.
+ */
+ function testRuleStillBlocksWhenItsListIsNarrowerThanTheRegistry() public {
+ vm.prank(AGENT);
+ token.mint(HOLDER, 100);
+
+ // OUTSIDER becomes identity-verified but is deliberately NOT added to the rule.
+ vm.prank(AGENT);
+ registry.registerIdentity(OUTSIDER, address(0), 0);
+ assertTrue(registry.isVerified(OUTSIDER), "identity slot would allow it");
+
+ vm.prank(HOLDER);
+ vm.expectRevert();
+ token.transfer(OUTSIDER, 40);
+ assertEq(token.balanceOf(OUTSIDER), 0);
+ }
+}
diff --git a/test/ERC3643Real/RuleIdentityRegistryWithRealERC3643Registry.t.sol b/test/ERC3643Real/RuleIdentityRegistryWithRealERC3643Registry.t.sol
new file mode 100644
index 00000000..53d791e1
--- /dev/null
+++ b/test/ERC3643Real/RuleIdentityRegistryWithRealERC3643Registry.t.sol
@@ -0,0 +1,268 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {IIdentity} from "test/utils/onchainid/interface/IIdentity.sol";
+import {IClaimIssuer} from "test/utils/onchainid/interface/IClaimIssuer.sol";
+import {ClaimTopicsRegistry} from "ERC3643/registry/implementation/ClaimTopicsRegistry.sol";
+import {IdentityRegistry} from "ERC3643/registry/implementation/IdentityRegistry.sol";
+import {IdentityRegistryStorage} from "ERC3643/registry/implementation/IdentityRegistryStorage.sol";
+import {TrustedIssuersRegistry} from "ERC3643/registry/implementation/TrustedIssuersRegistry.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {RuleIdentityRegistry} from "src/rules/validation/deployment/RuleIdentityRegistry.sol";
+import {ClaimIssuerMock, OnchainIdClaimMock} from "./utils/OnchainIdClaimMocks.sol";
+
+/**
+ * @title RuleIdentityRegistryWithRealERC3643Registry
+ * @notice `RuleIdentityRegistry` consulting the **genuine ERC-3643 `IdentityRegistry`** — the
+ * reference implementation vendored in `lib/ERC-3643`, with its real
+ * `IdentityRegistryStorage`, `ClaimTopicsRegistry` and `TrustedIssuersRegistry` behind it.
+ *
+ * `RuleEngine -> RuleIdentityRegistry -> ERC-3643 IdentityRegistry -> claims`
+ *
+ * @dev The companion suite `CMTATRuleIdentityRegistryComposition` runs the same rule against *this
+ * project's* `IdentityRegistryWhitelist`. This one answers the other half of the question: does
+ * the rule work against the registry the standard actually ships? Nothing else in the repo
+ * builds the reference `IdentityRegistry` — the existing `ERC3643Real` suites plug
+ * `IdentityRegistryWhitelist` into `Token.sol`'s identity slot precisely to avoid ONCHAINID.
+ *
+ * Two claim regimes are covered, because `isVerified` takes a different path through each:
+ * - **no claim topics required** — the registry short-circuits to "verified if an identity is
+ * registered", never touching ONCHAINID;
+ * - **one required topic** — the registry iterates topics, resolves trusted issuers, reads the
+ * claim off the investor's identity and asks the issuer to validate it.
+ *
+ * LIMITATION: the ONCHAINID doubles in `utils/OnchainIdClaimMocks.sol` implement only
+ * `getClaim` and `isClaimValid`. Signature verification, key management and revocation are NOT
+ * exercised — the *registry's* logic runs for real, ONCHAINID's does not. Extending the two
+ * stub interfaces under `test/utils/onchainid/` was required to make the reference registry
+ * compile at all; before this suite they declared only `keyHasPurpose`.
+ *
+ * This file lives in `test/ERC3643Real/` and therefore builds ONLY under
+ * `FOUNDRY_PROFILE=erc3643` (solc 0.8.30), like everything else that touches vendored ERC-3643.
+ */
+contract RuleIdentityRegistryWithRealERC3643Registry is Test {
+ uint256 private constant CLAIM_TOPIC_KYC = 7;
+ uint16 private constant COUNTRY_CH = 756;
+
+ address private constant ADMIN = address(1);
+ address private constant AGENT = address(2);
+ address private constant ALICE = address(11);
+ address private constant BOB = address(12);
+ address private constant CAROL = address(13);
+ address private constant SPENDER = address(14);
+
+ uint8 private constant TRANSFER_OK = 0;
+ uint8 private constant CODE_ADDRESS_TO_NOT_VERIFIED = 56;
+
+ IdentityRegistry private registry;
+ ClaimTopicsRegistry private claimTopics;
+ TrustedIssuersRegistry private trustedIssuers;
+ IdentityRegistryStorage private identityStorage;
+
+ ClaimIssuerMock private issuer;
+ RuleIdentityRegistry private rule;
+ RuleEngine private ruleEngine;
+
+ mapping(address wallet => OnchainIdClaimMock) private identityOf;
+
+ function setUp() public {
+ vm.startPrank(ADMIN);
+ claimTopics = new ClaimTopicsRegistry();
+ claimTopics.init();
+
+ trustedIssuers = new TrustedIssuersRegistry();
+ trustedIssuers.init();
+
+ identityStorage = new IdentityRegistryStorage();
+ identityStorage.init();
+
+ registry = new IdentityRegistry();
+ registry.init(address(trustedIssuers), address(claimTopics), address(identityStorage));
+
+ // The storage must accept writes from this registry, and the agent performs registrations.
+ identityStorage.bindIdentityRegistry(address(registry));
+ registry.addAgent(AGENT);
+
+ issuer = new ClaimIssuerMock();
+
+ // The rule under test consults the real registry. ERC-3643 defaults: receiver-only.
+ rule = new RuleIdentityRegistry(ADMIN, address(registry), false, false);
+ ruleEngine = new RuleEngine(ADMIN, address(0), address(0));
+ ruleEngine.addRule(rule);
+ vm.stopPrank();
+ }
+
+ /**
+ * @dev Gives `wallet` an ONCHAINID and registers it with the ERC-3643 registry.
+ */
+ function _register(address wallet) internal returns (OnchainIdClaimMock id) {
+ id = new OnchainIdClaimMock();
+ identityOf[wallet] = id;
+ vm.prank(AGENT);
+ registry.registerIdentity(wallet, IIdentity(address(id)), COUNTRY_CH);
+ }
+
+ /**
+ * @dev Switches the token to requiring one KYC claim from `issuer`.
+ */
+ function _requireKycClaim() internal {
+ uint256[] memory topics = new uint256[](1);
+ topics[0] = CLAIM_TOPIC_KYC;
+ vm.startPrank(ADMIN);
+ claimTopics.addClaimTopic(CLAIM_TOPIC_KYC);
+ trustedIssuers.addTrustedIssuer(IClaimIssuer(address(issuer)), topics);
+ vm.stopPrank();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Regime 1 — no claim topics required
+ //////////////////////////////////////////////////////////////*/
+
+ function testWiringPointsAtTheRealRegistry() public view {
+ assertEq(address(rule.identityRegistry()), address(registry));
+ }
+
+ function testUnregisteredReceiverIsRejected() public view {
+ // No identity registered at all: the reference registry returns false, the rule maps that
+ // onto its own restriction code, and the engine surfaces it.
+ assertFalse(registry.isVerified(BOB));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ assertFalse(ruleEngine.canTransfer(ALICE, BOB, 10));
+ }
+
+ function testRegisteredReceiverPassesWhenNoClaimsAreRequired() public {
+ _register(BOB);
+ assertTrue(registry.isVerified(BOB), "no required topics => a registered identity is verified");
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+ assertTrue(ruleEngine.canTransfer(ALICE, BOB, 10));
+ }
+
+ function testSenderNeedNotBeRegistered() public {
+ // ERC-3643 screens only the receiver; this is what lets a de-listed holder exit.
+ _register(BOB);
+ assertFalse(registry.isVerified(ALICE), "premise: the sender is unregistered");
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+ }
+
+ function testDeleteIdentityImmediatelyBlocksTheReceiver() public {
+ _register(BOB);
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+
+ vm.prank(AGENT);
+ registry.deleteIdentity(BOB);
+
+ assertFalse(registry.isVerified(BOB));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Regime 2 — one required claim topic
+ //////////////////////////////////////////////////////////////*/
+
+ function testRegisteredButUnclaimedReceiverIsRejected() public {
+ _register(BOB);
+ _requireKycClaim();
+
+ // Registered, but holds no KYC claim: the registry now walks the claim path and fails.
+ assertFalse(registry.isVerified(BOB), "a required topic with no claim must fail");
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ function testReceiverWithAValidClaimPasses() public {
+ OnchainIdClaimMock id = _register(BOB);
+ _requireKycClaim();
+ id.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+
+ assertTrue(registry.isVerified(BOB), "claim from a trusted issuer must verify");
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+ }
+
+ function testRevokingTheClaimBlocksTheReceiver() public {
+ OnchainIdClaimMock id = _register(BOB);
+ _requireKycClaim();
+ id.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+
+ // The issuer invalidates its claims — the registry asks it, so the answer flips.
+ issuer.setClaimsValid(false);
+
+ assertFalse(registry.isVerified(BOB));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ function testClaimFromAnUntrustedIssuerDoesNotVerify() public {
+ OnchainIdClaimMock id = _register(BOB);
+ _requireKycClaim();
+
+ // A claim on the right topic, but signed by an issuer the registry does not trust.
+ ClaimIssuerMock rogue = new ClaimIssuerMock();
+ id.addClaim(CLAIM_TOPIC_KYC, address(rogue));
+
+ assertFalse(registry.isVerified(BOB), "only trusted issuers count");
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ The rule's opt-in flags against the real registry
+ //////////////////////////////////////////////////////////////*/
+
+ function testCheckSenderOptInScreensTheSenderToo() public {
+ OnchainIdClaimMock bobId = _register(BOB);
+ _requireKycClaim();
+ bobId.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+
+ vm.prank(ADMIN);
+ rule.setCheckSender(true);
+
+ // ALICE is unregistered, so the stricter screening now rejects the same transfer.
+ assertFalse(registry.isVerified(ALICE));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), 55); // CODE_ADDRESS_FROM_NOT_VERIFIED
+
+ OnchainIdClaimMock aliceId = _register(ALICE);
+ aliceId.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, BOB, 10), TRANSFER_OK);
+ }
+
+ function testCheckSpenderOptInScreensTheSpender() public {
+ OnchainIdClaimMock bobId = _register(BOB);
+ _requireKycClaim();
+ bobId.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+
+ // Default: the spender is not screened.
+ assertEq(ruleEngine.detectTransferRestrictionFrom(SPENDER, ALICE, BOB, 10), TRANSFER_OK);
+
+ vm.prank(ADMIN);
+ rule.setCheckSpender(true);
+ assertEq(ruleEngine.detectTransferRestrictionFrom(SPENDER, ALICE, BOB, 10), 57); // SPENDER_NOT_VERIFIED
+
+ OnchainIdClaimMock spenderId = _register(SPENDER);
+ spenderId.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+ assertEq(ruleEngine.detectTransferRestrictionFrom(SPENDER, ALICE, BOB, 10), TRANSFER_OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Mint / burn against the real registry
+ //////////////////////////////////////////////////////////////*/
+
+ function testMintIsScreenedOnTheReceiverOnly() public {
+ OnchainIdClaimMock id = _register(BOB);
+ _requireKycClaim();
+ id.addClaim(CLAIM_TOPIC_KYC, address(issuer));
+
+ // from == address(0): the registry is never asked about the sentinel.
+ assertFalse(registry.isVerified(address(0)), "the sentinel is not a wallet");
+ assertEq(ruleEngine.detectTransferRestriction(address(0), BOB, 10), TRANSFER_OK);
+
+ // ...and an unverified recipient still blocks the mint.
+ assertEq(ruleEngine.detectTransferRestriction(address(0), CAROL, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ function testBurnBypassesEligibility() public {
+ // ERC-3643: "The `burn` function bypasses all checks on eligibility."
+ _requireKycClaim();
+ assertFalse(registry.isVerified(ALICE));
+ assertEq(ruleEngine.detectTransferRestriction(ALICE, address(0), 10), TRANSFER_OK);
+ }
+}
diff --git a/test/ERC3643Real/utils/OnchainIdClaimMocks.sol b/test/ERC3643Real/utils/OnchainIdClaimMocks.sol
new file mode 100644
index 00000000..10129ef9
--- /dev/null
+++ b/test/ERC3643Real/utils/OnchainIdClaimMocks.sol
@@ -0,0 +1,109 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IIdentity} from "test/utils/onchainid/interface/IIdentity.sol";
+import {IClaimIssuer} from "test/utils/onchainid/interface/IClaimIssuer.sol";
+
+/**
+ * @title OnchainIdClaimMocks
+ * @notice Minimal ONCHAINID doubles, enough to drive the **real** ERC-3643 `IdentityRegistry`
+ * through its full `isVerified` path including claim validation.
+ * @dev ONCHAINID is an npm package rather than a submodule, so it is not vendored (see
+ * `test/utils/onchainid/`). These mocks implement only what `IdentityRegistry.isVerified`
+ * actually calls: `getClaim` on the investor's identity, and `isClaimValid` on the trusted
+ * issuer of each required claim topic.
+ *
+ * WARNING: these are NOT ONCHAINID. There is no key management, no signature verification and
+ * no revocation. They exist so the registry's *own* logic — identity lookup, claim-topic
+ * iteration, trusted-issuer resolution — runs for real; they are not a model of ONCHAINID
+ * behaviour and must not be used to reason about it.
+ */
+
+/**
+ * @notice A claim issuer that accepts or rejects claims on command.
+ */
+contract ClaimIssuerMock is IClaimIssuer {
+ /**
+ * @notice When false, every claim this issuer signed is treated as invalid.
+ */
+ bool public claimsValid = true;
+
+ function setClaimsValid(bool value) external {
+ claimsValid = value;
+ }
+
+ /**
+ * @inheritdoc IClaimIssuer
+ */
+ function isClaimValid(IIdentity, uint256, bytes calldata, bytes calldata) external view override returns (bool) {
+ return claimsValid;
+ }
+
+ /**
+ * @inheritdoc IIdentity
+ */
+ function keyHasPurpose(bytes32, uint256) external pure override returns (bool) {
+ return true;
+ }
+
+ /**
+ * @inheritdoc IIdentity
+ */
+ function getClaim(bytes32)
+ external
+ pure
+ override
+ returns (uint256, uint256, address, bytes memory, bytes memory, string memory)
+ {
+ return (0, 0, address(0), "", "", "");
+ }
+}
+
+/**
+ * @notice An investor identity holding one claim per topic, all from the same issuer.
+ * @dev `IdentityRegistry.isVerified` looks a claim up by `keccak256(abi.encode(issuer, topic))`, so
+ * the mock stores claims under that key and returns topic `0` for anything it does not hold —
+ * which is what makes the registry treat the identity as failing that topic.
+ */
+contract OnchainIdClaimMock is IIdentity {
+ struct Claim {
+ uint256 topic;
+ address issuer;
+ }
+
+ mapping(bytes32 claimId => Claim) private _claims;
+
+ /**
+ * @notice Grants this identity a claim on `topic` issued by `issuer`.
+ */
+ function addClaim(uint256 topic, address issuer) external {
+ _claims[keccak256(abi.encode(issuer, topic))] = Claim({topic: topic, issuer: issuer});
+ }
+
+ /**
+ * @notice Removes a claim, so the identity stops satisfying that topic.
+ */
+ function removeClaim(uint256 topic, address issuer) external {
+ delete _claims[keccak256(abi.encode(issuer, topic))];
+ }
+
+ /**
+ * @inheritdoc IIdentity
+ */
+ function getClaim(bytes32 claimId)
+ external
+ view
+ override
+ returns (uint256, uint256, address, bytes memory, bytes memory, string memory)
+ {
+ Claim memory c = _claims[claimId];
+ return (c.topic, 1, c.issuer, "", "", "");
+ }
+
+ /**
+ * @inheritdoc IIdentity
+ */
+ function keyHasPurpose(bytes32, uint256) external pure override returns (bool) {
+ return true;
+ }
+}
diff --git a/test/Events/BatchEventEffect.t.sol b/test/Events/BatchEventEffect.t.sol
new file mode 100644
index 00000000..9309110f
--- /dev/null
+++ b/test/Events/BatchEventEffect.t.sol
@@ -0,0 +1,171 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test, Vm} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {IAddressList} from "src/rules/interfaces/IAddressList.sol";
+import {RuleERC2980} from "src/rules/validation/deployment/RuleERC2980.sol";
+import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+import {
+ RuleERC2980InvariantStorage
+} from "src/rules/validation/abstract/RuleERC2980/invariantStorage/RuleERC2980InvariantStorage.sol";
+
+/**
+ * @title BatchEventEffect
+ * @notice Batch events must report what actually changed, not just what was submitted
+ * (`CLAUDE_ANALYSIS.md` C-4).
+ * @dev A batch skips entries already present (or already absent, on removal), so the input array
+ * alone cannot tell a consumer whether anything happened: a batch of 100 fresh members and a
+ * batch of 100 no-ops emitted the identical event. The `added` / `removed` / `skipped` counters
+ * were being computed inside the loops and then discarded by every caller.
+ *
+ * These tests pin the counters at the boundary that matters — a batch that is *partly* a no-op —
+ * for the shared `RuleAddressSet` machinery and for `RuleERC2980`, which keeps its own copy of
+ * the same loops for its whitelist and its frozenlist.
+ */
+contract BatchEventEffect is Test, HelperContract {
+ RuleWhitelist private rule;
+ RuleERC2980 private erc2980;
+
+ function setUp() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, false, true);
+ erc2980 = new RuleERC2980(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, true);
+ vm.stopPrank();
+ }
+
+ function _three() internal pure returns (address[] memory a) {
+ a = new address[](3);
+ a[0] = ADDRESS1;
+ a[1] = ADDRESS2;
+ a[2] = ADDRESS3;
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ RuleAddressSet machinery
+ //////////////////////////////////////////////////////////////*/
+
+ function testAddAddressesReportsAllNew() public {
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.AddAddresses(batch, 3, 0);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddresses(batch);
+ }
+
+ /**
+ * @notice The case the input array cannot express: a batch that changes nothing.
+ * @dev Before C-4 this emitted an event byte-identical to the one above.
+ */
+ function testAddAddressesReportsAFullyRedundantBatch() public {
+ address[] memory batch = _three();
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddresses(batch);
+
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.AddAddresses(batch, 0, 3); // nothing added, all skipped
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddresses(batch);
+
+ assertEq(rule.listedAddressCount(), 3, "a redundant batch must not change the set");
+ }
+
+ function testAddAddressesReportsAPartialBatch() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS2); // one already present
+
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.AddAddresses(batch, 2, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddresses(batch);
+
+ assertEq(rule.listedAddressCount(), 3);
+ }
+
+ function testRemoveAddressesReportsAPartialBatch() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS1);
+ rule.addAddress(ADDRESS2);
+ vm.stopPrank();
+
+ address[] memory batch = _three(); // ADDRESS3 was never listed
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.RemoveAddresses(batch, 2, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.removeAddresses(batch);
+
+ assertEq(rule.listedAddressCount(), 0);
+ }
+
+ /**
+ * @notice The counters must always account for the whole input.
+ */
+ function testFuzz_CountersSumToTheInputLength(uint8 preloaded) public {
+ vm.assume(preloaded <= 3);
+ address[] memory batch = _three();
+
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ for (uint256 i = 0; i < preloaded; ++i) {
+ rule.addAddress(batch[i]);
+ }
+ vm.recordLogs();
+ rule.addAddresses(batch);
+ vm.stopPrank();
+
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+ (, uint256 added, uint256 skipped) = abi.decode(logs[logs.length - 1].data, (address[], uint256, uint256));
+ assertEq(added + skipped, batch.length, "added + skipped must cover the input");
+ assertEq(skipped, preloaded, "skipped must equal what was already present");
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ RuleERC2980 — its own copy of the same loops
+ //////////////////////////////////////////////////////////////*/
+
+ function testWhitelistBatchReportsAPartialBatch() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addWhitelistAddress(ADDRESS1);
+
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit RuleERC2980InvariantStorage.AddWhitelistAddresses(batch, 2, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addWhitelistAddresses(batch);
+ }
+
+ function testFrozenlistBatchReportsAPartialBatch() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addFrozenlistAddress(ADDRESS3);
+
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit RuleERC2980InvariantStorage.AddFrozenlistAddresses(batch, 2, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addFrozenlistAddresses(batch);
+ }
+
+ function testFrozenlistRemoveReportsAPartialBatch() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addFrozenlistAddress(ADDRESS1);
+
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit RuleERC2980InvariantStorage.RemoveFrozenlistAddresses(batch, 1, 2);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.removeFrozenlistAddresses(batch);
+ }
+
+ function testWhitelistRemoveReportsAPartialBatch() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.addWhitelistAddress(ADDRESS1);
+ erc2980.addWhitelistAddress(ADDRESS2);
+ vm.stopPrank();
+
+ address[] memory batch = _three();
+ vm.expectEmit(true, true, true, true);
+ emit RuleERC2980InvariantStorage.RemoveWhitelistAddresses(batch, 2, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ erc2980.removeWhitelistAddresses(batch);
+ }
+}
diff --git a/test/Events/ConstructorEvents.t.sol b/test/Events/ConstructorEvents.t.sol
new file mode 100644
index 00000000..597687c8
--- /dev/null
+++ b/test/Events/ConstructorEvents.t.sol
@@ -0,0 +1,174 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test, Vm} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {IdentityRegistryMock} from "src/mocks/IdentityRegistryMock.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoR} from "src/rules/validation/deployment/RuleChainlinkPoR.sol";
+import {RuleIdentityRegistry} from "src/rules/validation/deployment/RuleIdentityRegistry.sol";
+import {RuleMaxTotalSupply} from "src/rules/validation/deployment/RuleMaxTotalSupply.sol";
+import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+import {RuleWhitelistWrapper} from "src/rules/validation/deployment/RuleWhitelistWrapper.sol";
+
+/**
+ * @title ConstructorEvents
+ * @notice Every configuration value assigned at deployment must be announced, so a rule that is
+ * configured once and never touched can still be reconstructed from events alone
+ * (`CLAUDE_ANALYSIS.md` C-1, C-2, C-3).
+ * @dev Matched on `topic0` rather than through `vm.expectEmit` so the assertions do not depend on
+ * event-declaration visibility, and so an event that is emitted the *wrong number of times* is
+ * caught as well as one that is missing. `RuleChainlinkPoR` is included although it was already
+ * correct: it is the rule the others were made to match, and pinning it stops the convention
+ * regressing from the other direction.
+ */
+contract ConstructorEvents is Test, HelperContract {
+ bytes32 private constant MAX_TOTAL_SUPPLY_UPDATED = keccak256("MaxTotalSupplyUpdated(uint256)");
+ bytes32 private constant TOKEN_CONTRACT_UPDATED = keccak256("TokenContractUpdated(address)");
+ bytes32 private constant CHECK_SPENDER_UPDATED = keccak256("CheckSpenderUpdated(bool)");
+ bytes32 private constant ALLOW_MINT_UPDATED = keccak256("AllowMintUpdated(bool)");
+ bytes32 private constant ALLOW_BURN_UPDATED = keccak256("AllowBurnUpdated(bool)");
+ bytes32 private constant IDENTITY_REGISTRY_UPDATED = keccak256("IdentityRegistryUpdated(address)");
+ bytes32 private constant IDENTITY_CHECK_SENDER_UPDATED = keccak256("IdentityCheckSenderUpdated(bool)");
+ bytes32 private constant IDENTITY_CHECK_SPENDER_UPDATED = keccak256("IdentityCheckSpenderUpdated(bool)");
+ bytes32 private constant RESERVES_FEED_UPDATED = keccak256("ReservesFeedUpdated(address,uint8)");
+ bytes32 private constant TOKEN_METADATA_UPDATED = keccak256("TokenMetadataUpdated(address,uint8)");
+ bytes32 private constant MAX_STALENESS_UPDATED = keccak256("MaxStalenessSecondsUpdated(uint256)");
+
+ /**
+ * @dev Number of recorded logs whose `topic0` matches `sig`.
+ */
+ function _count(Vm.Log[] memory logs, bytes32 sig) internal pure returns (uint256 n) {
+ for (uint256 i = 0; i < logs.length; ++i) {
+ if (logs[i].topics.length != 0 && logs[i].topics[0] == sig) {
+ ++n;
+ }
+ }
+ }
+
+ /**
+ * @dev The single log matching `sig`; reverts the test if there is not exactly one.
+ */
+ function _only(Vm.Log[] memory logs, bytes32 sig) internal pure returns (Vm.Log memory found) {
+ uint256 seen;
+ for (uint256 i = 0; i < logs.length; ++i) {
+ if (logs[i].topics.length != 0 && logs[i].topics[0] == sig) {
+ found = logs[i];
+ ++seen;
+ }
+ }
+ require(seen == 1, "expected exactly one matching log");
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ C-1 — RuleMaxTotalSupply
+ //////////////////////////////////////////////////////////////*/
+
+ function testMaxTotalSupplyAnnouncesItsDeploymentConfiguration() public {
+ TotalSupplyMock token = new TotalSupplyMock();
+
+ vm.recordLogs();
+ new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, address(token), 4242);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ assertEq(_count(logs, TOKEN_CONTRACT_UPDATED), 1, "TokenContractUpdated");
+ assertEq(_count(logs, MAX_TOTAL_SUPPLY_UPDATED), 1, "MaxTotalSupplyUpdated");
+
+ // The token address is indexed; the cap is in the data.
+ assertEq(address(uint160(uint256(_only(logs, TOKEN_CONTRACT_UPDATED).topics[1]))), address(token));
+ assertEq(abi.decode(_only(logs, MAX_TOTAL_SUPPLY_UPDATED).data, (uint256)), 4242);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ C-2 — checkSpender on both whitelists
+ //////////////////////////////////////////////////////////////*/
+
+ function testWhitelistAnnouncesCheckSpenderAlongsideTheMintBurnFlags() public {
+ vm.recordLogs();
+ new RuleWhitelist(WHITELIST_OPERATOR_ADDRESS, ZERO_ADDRESS, true, false);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ assertEq(_count(logs, CHECK_SPENDER_UPDATED), 1, "CheckSpenderUpdated");
+ assertEq(_count(logs, ALLOW_MINT_UPDATED), 1, "AllowMintUpdated");
+ assertEq(_count(logs, ALLOW_BURN_UPDATED), 1, "AllowBurnUpdated");
+
+ assertTrue(abi.decode(_only(logs, CHECK_SPENDER_UPDATED).data, (bool)), "checkSpender = true");
+ assertFalse(abi.decode(_only(logs, ALLOW_MINT_UPDATED).data, (bool)), "allowMint = false");
+ }
+
+ function testWhitelistWrapperAnnouncesCheckSpenderAlongsideTheMintBurnFlags() public {
+ vm.recordLogs();
+ new RuleWhitelistWrapper(WHITELIST_OPERATOR_ADDRESS, ZERO_ADDRESS, false, true);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ assertEq(_count(logs, CHECK_SPENDER_UPDATED), 1, "CheckSpenderUpdated");
+ assertEq(_count(logs, ALLOW_MINT_UPDATED), 1, "AllowMintUpdated");
+ assertEq(_count(logs, ALLOW_BURN_UPDATED), 1, "AllowBurnUpdated");
+
+ assertFalse(abi.decode(_only(logs, CHECK_SPENDER_UPDATED).data, (bool)), "checkSpender = false");
+ assertTrue(abi.decode(_only(logs, ALLOW_MINT_UPDATED).data, (bool)), "allowMint = true");
+ }
+
+ function testSetCheckSpenderStillEmitsExactlyOnce() public {
+ RuleWhitelist rule = new RuleWhitelist(WHITELIST_OPERATOR_ADDRESS, ZERO_ADDRESS, false, true);
+
+ vm.recordLogs();
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ rule.setCheckSpender(true);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ // Moving the emit into the internal helper must not double-emit from the public setter.
+ assertEq(_count(logs, CHECK_SPENDER_UPDATED), 1);
+ assertTrue(abi.decode(_only(logs, CHECK_SPENDER_UPDATED).data, (bool)));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ C-3 — RuleIdentityRegistry
+ //////////////////////////////////////////////////////////////*/
+
+ function testIdentityRegistryAnnouncesAConfiguredRegistry() public {
+ IdentityRegistryMock registry = new IdentityRegistryMock();
+
+ vm.recordLogs();
+ new RuleIdentityRegistry(DEFAULT_ADMIN_ADDRESS, address(registry), true, false);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ assertEq(_count(logs, IDENTITY_REGISTRY_UPDATED), 1, "IdentityRegistryUpdated");
+ assertEq(_count(logs, IDENTITY_CHECK_SENDER_UPDATED), 1, "IdentityCheckSenderUpdated");
+ assertEq(_count(logs, IDENTITY_CHECK_SPENDER_UPDATED), 1, "IdentityCheckSpenderUpdated");
+
+ assertEq(address(uint160(uint256(_only(logs, IDENTITY_REGISTRY_UPDATED).topics[1]))), address(registry));
+ assertTrue(abi.decode(_only(logs, IDENTITY_CHECK_SENDER_UPDATED).data, (bool)), "checkSender = true");
+ }
+
+ function testIdentityRegistryStaysSilentWhenNoRegistryIsAssigned() public {
+ vm.recordLogs();
+ new RuleIdentityRegistry(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, false, false);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ // Nothing was assigned, so there is nothing to announce -- an `IdentityRegistryUpdated(0)`
+ // here would be indistinguishable from a deliberate `clearIdentityRegistry()`.
+ assertEq(_count(logs, IDENTITY_REGISTRY_UPDATED), 0, "no registry assigned");
+ assertEq(_count(logs, IDENTITY_CHECK_SENDER_UPDATED), 1, "flags are always assigned");
+ assertEq(_count(logs, IDENTITY_CHECK_SPENDER_UPDATED), 1);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Reference: the rule the others were made to match
+ //////////////////////////////////////////////////////////////*/
+
+ function testChainlinkPoRStillAnnouncesItsWholeConfiguration() public {
+ TotalSupplyMock token = new TotalSupplyMock();
+ AggregatorV3Mock feed = new AggregatorV3Mock(8, 1000e8);
+
+ vm.recordLogs();
+ new RuleChainlinkPoR(DEFAULT_ADMIN_ADDRESS, address(token), 0, AggregatorV3Interface(address(feed)), 3600);
+ Vm.Log[] memory logs = vm.getRecordedLogs();
+
+ assertEq(_count(logs, RESERVES_FEED_UPDATED), 1, "ReservesFeedUpdated");
+ assertEq(_count(logs, TOKEN_METADATA_UPDATED), 1, "TokenMetadataUpdated");
+ assertEq(_count(logs, MAX_STALENESS_UPDATED), 1, "MaxStalenessSecondsUpdated");
+ }
+}
diff --git a/test/HelperContract.sol b/test/HelperContract.sol
index f833da54..1c344e06 100644
--- a/test/HelperContract.sol
+++ b/test/HelperContract.sol
@@ -29,6 +29,9 @@ import {
import {
RuleAddressSetInvariantStorage
} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol";
+import {
+ RuleAddressSetRolesStorage
+} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetRolesStorage.sol";
import {
RuleMaxTotalSupplyInvariantStorage
} from "src/rules/validation/abstract/invariant/RuleMaxTotalSupplyInvariantStorage.sol";
@@ -40,6 +43,10 @@ import {
RuleSanctionsListInvariantStorage
} from "src/rules/validation/abstract/invariant/RuleSanctionsListInvariantStorage.sol";
+import {
+ RuleChainlinkPoRInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleChainlinkPoRInvariantStorage.sol";
+
// utils
import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
@@ -50,8 +57,10 @@ abstract contract HelperContract is
RuleWhitelistInvariantStorage,
RuleBlacklistInvariantStorage,
RuleAddressSetInvariantStorage,
+ RuleAddressSetRolesStorage,
RuleSanctionsListInvariantStorage,
RuleMaxTotalSupplyInvariantStorage,
+ RuleChainlinkPoRInvariantStorage,
RuleIdentityRegistryInvariantStorage,
RuleConditionalTransferLightInvariantStorage,
RuleMintAllowanceInvariantStorage,
diff --git a/test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol b/test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol
new file mode 100644
index 00000000..990ce98c
--- /dev/null
+++ b/test/IdentityRegistryWhitelist/CMTATRuleIdentityRegistryComposition.t.sol
@@ -0,0 +1,273 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {RuleIdentityRegistry} from "src/rules/validation/deployment/RuleIdentityRegistry.sol";
+
+/**
+ * @title CMTATRuleIdentityRegistryComposition
+ * @notice End-to-end test of the two halves of this library's identity story working together:
+ * `RuleIdentityRegistry` *consults* an identity registry, `IdentityRegistryWhitelist` *is*
+ * one. Chain under test:
+ *
+ * `CMTAT -> RuleEngine -> RuleIdentityRegistry -> IdentityRegistryWhitelist`
+ *
+ * @dev Until now each half was only ever tested against the other side's stand-in:
+ * `RuleIdentityRegistry` against `IdentityRegistryMock`, and `IdentityRegistryWhitelist` inside
+ * an **ERC-3643** token's identity slot. This suite closes that gap.
+ *
+ * It matters more than a routine composition test, because **CMTAT has no
+ * `setIdentityRegistry` slot** -- that is an ERC-3643 concept. For a CMTAT token this chain is
+ * not one option among several, it is the *only* way to use `IdentityRegistryWhitelist` at all,
+ * and this library exists to serve CMTAT.
+ *
+ * Note the two contracts are wired by interface, not by inheritance: the rule holds an
+ * `IIdentityRegistryVerified` and only ever calls `isVerified(address)`, which the registry
+ * implements as part of its ERC-3643 surface. Nothing in either contract references the other.
+ */
+contract CMTATRuleIdentityRegistryComposition is Test, HelperContract {
+ IdentityRegistryWhitelist private registry;
+ RuleIdentityRegistry private rule;
+
+ address private constant REGISTRAR = address(20);
+ address private constant MINTER = address(21);
+ address private constant BURNER = address(22);
+ address private constant SPENDER = address(23);
+
+ function setUp() public {
+ cmtatDeployment = new CMTATDeployment();
+ cmtatContract = cmtatDeployment.cmtat();
+
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ // The registry IS an identity registry...
+ registry = new IdentityRegistryWhitelist(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registry.IDENTITY_REGISTRAR_ROLE(), REGISTRAR);
+
+ // ...and the rule CONSULTS it. ERC-3643 defaults: receiver-only screening.
+ rule = new RuleIdentityRegistry(DEFAULT_ADMIN_ADDRESS, address(registry), false, false);
+
+ ruleEngineMock = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, address(cmtatContract));
+ ruleEngineMock.addRule(rule);
+ cmtatContract.setRuleEngine(ruleEngineMock);
+
+ cmtatContract.grantRole(keccak256("MINTER_ROLE"), MINTER);
+ cmtatContract.grantRole(keccak256("BURNER_ROLE"), BURNER);
+ vm.stopPrank();
+ }
+
+ function _register(address user) internal {
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(user, address(0xADD1), 756);
+ }
+
+ function _delist(address user) internal {
+ vm.prank(REGISTRAR);
+ registry.deleteIdentity(user);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Wiring
+ //////////////////////////////////////////////////////////////*/
+
+ function testTheRuleReadsTheRegistryItWasGiven() public {
+ assertEq(address(rule.identityRegistry()), address(registry), "rule must point at the registry");
+
+ // A registration made on the registry is immediately visible through the rule and the engine.
+ assertEq(ruleEngineMock.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ _register(ADDRESS2);
+ assertEq(ruleEngineMock.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ assertTrue(registry.isVerified(ADDRESS2));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Mint / transfer / burn
+ //////////////////////////////////////////////////////////////*/
+
+ function testMintToARegisteredWalletSucceeds() public {
+ _register(ADDRESS1);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 100);
+ }
+
+ function testMintToAnUnregisteredWalletReverts() public {
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, 100);
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 0);
+ }
+
+ function testMintSucceedsEvenThoughTheMinterIsNotRegistered() public {
+ // ERC-3643: mint "only require[s] the receiver to be whitelisted and verified".
+ _register(ADDRESS1);
+ assertFalse(registry.isVerified(MINTER), "premise: the minter is NOT registered");
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 100);
+ }
+
+ function testTransferBetweenRegisteredWalletsSucceeds() public {
+ _register(ADDRESS1);
+ _register(ADDRESS2);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ vm.prank(ADDRESS1);
+ cmtatContract.transfer(ADDRESS2, 40);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 40);
+ }
+
+ function testTransferToAnUnregisteredWalletReverts() public {
+ _register(ADDRESS1);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ cmtatContract.transfer(ADDRESS2, 40);
+ }
+
+ function testBurnBypassesEligibility() public {
+ // ERC-3643: "The `burn` function bypasses all checks on eligibility."
+ _register(ADDRESS1);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ _delist(ADDRESS1);
+ assertFalse(registry.isVerified(ADDRESS1), "premise: holder is de-listed");
+
+ vm.prank(BURNER);
+ cmtatContract.burn(ADDRESS1, 100);
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ The de-listed holder can still exit (invariant I-1)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The property the whole receiver-only design exists for, exercised against the real
+ * registry rather than a mock: an investor whose identity is deleted can still sell out
+ * to a verified counterparty, but can no longer receive.
+ */
+ function testDelistedHolderCanStillSendButNotReceive() public {
+ _register(ADDRESS1);
+ _register(ADDRESS2);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ _delist(ADDRESS1);
+
+ // Can still SEND to a verified counterparty — the exit is open.
+ vm.prank(ADDRESS1);
+ cmtatContract.transfer(ADDRESS2, 60);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 60);
+
+ // Can no longer RECEIVE.
+ vm.prank(ADDRESS2);
+ vm.expectRevert();
+ cmtatContract.transfer(ADDRESS1, 10);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Zero address is never verified
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `isVerified(address(0))` must be false (invariant I-12), and that must not break mint
+ * or burn — the rule never asks the registry about the sentinel.
+ */
+ function testZeroAddressIsNeverVerifiedAndMintBurnStillWork() public {
+ assertFalse(registry.isVerified(ZERO_ADDRESS), "the sentinel is not a wallet");
+
+ _register(ADDRESS1);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100); // from == address(0)
+ vm.prank(BURNER);
+ cmtatContract.burn(ADDRESS1, 100); // to == address(0)
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Opt-in stricter screening
+ //////////////////////////////////////////////////////////////*/
+
+ function testCheckSenderOptInTrapsTheDelistedHolder() public {
+ // Documented consequence of the opt-in: enabling it removes the exit.
+ _register(ADDRESS1);
+ _register(ADDRESS2);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setCheckSender(true);
+ _delist(ADDRESS1);
+
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ cmtatContract.transfer(ADDRESS2, 10);
+ }
+
+ function testCheckSpenderOptInScreensTheSpenderOnTransferFrom() public {
+ _register(ADDRESS1);
+ _register(ADDRESS2);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ vm.prank(ADDRESS1);
+ cmtatContract.approve(SPENDER, 50);
+
+ // Default: the spender is not screened, so an unregistered spender may move funds.
+ assertFalse(registry.isVerified(SPENDER));
+ vm.prank(SPENDER);
+ cmtatContract.transferFrom(ADDRESS1, ADDRESS2, 10);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 10);
+
+ // Opt in, and the same call is now rejected until the spender is registered.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setCheckSpender(true);
+
+ vm.prank(SPENDER);
+ vm.expectRevert();
+ cmtatContract.transferFrom(ADDRESS1, ADDRESS2, 10);
+
+ _register(SPENDER);
+ vm.prank(SPENDER);
+ cmtatContract.transferFrom(ADDRESS1, ADDRESS2, 10);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 20);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Registry writes reach the token
+ //////////////////////////////////////////////////////////////*/
+
+ function testRegistrarRoleGatesTheWholeChain() public {
+ // Nobody but the registrar can make a wallet transferable.
+ vm.expectRevert();
+ vm.prank(ADDRESS3);
+ registry.registerIdentity(ADDRESS1, address(0xADD1), 756);
+
+ assertEq(ruleEngineMock.detectTransferRestriction(ADDRESS3, ADDRESS1, 10), CODE_ADDRESS_TO_NOT_VERIFIED);
+ }
+
+ function testDeleteIdentityImmediatelyBlocksInboundTransfers() public {
+ _register(ADDRESS1);
+ _register(ADDRESS2);
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, 100);
+
+ vm.prank(ADDRESS1);
+ cmtatContract.transfer(ADDRESS2, 10);
+
+ _delist(ADDRESS2);
+
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ cmtatContract.transfer(ADDRESS2, 10);
+ assertEq(cmtatContract.balanceOf(ADDRESS2), 10, "balance unchanged after the rejected transfer");
+ }
+}
diff --git a/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistERC3643.t.sol b/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistERC3643.t.sol
new file mode 100644
index 00000000..61410a53
--- /dev/null
+++ b/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistERC3643.t.sol
@@ -0,0 +1,257 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {ERC3643TokenMock} from "src/mocks/ERC3643TokenMock.sol";
+import {OnchainIdMock} from "src/mocks/OnchainIdMock.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {
+ IdentityRegistryWhitelistInvariantStorage
+} from "src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol";
+import {IIdentityRegistryERC3643} from "src/registry/interfaces/IIdentityRegistryERC3643.sol";
+
+/**
+ * @title Integration tests: ERC-3643 token + IdentityRegistryWhitelist
+ * @notice Exercises every ERC-3643 entrypoint that touches the identity registry -- `transfer`,
+ * `transferFrom`, `forcedTransfer`, `mint`, `burn` and `recoveryAddress` -- against the
+ * whitelist-backed registry, using a token whose registry call sequences are transcribed
+ * from the reference `Token.sol`.
+ */
+contract IdentityRegistryWhitelistERC3643 is Test, HelperContract, IdentityRegistryWhitelistInvariantStorage {
+ address constant AGENT = address(10);
+ address constant INVESTOR = address(11);
+ address constant INVESTOR2 = address(12);
+ address constant NEW_WALLET = address(13);
+ address constant OUTSIDER = address(14);
+ uint16 constant COUNTRY_CH = 756;
+
+ IdentityRegistryWhitelist private registry;
+ ERC3643TokenMock private token;
+ OnchainIdMock private investorOnchainId;
+ bytes32 private registrarRole;
+
+ function setUp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry = new IdentityRegistryWhitelist(DEFAULT_ADMIN_ADDRESS);
+
+ token = new ERC3643TokenMock(IIdentityRegistryERC3643(address(registry)), AGENT);
+ // The registry no longer answers `keyHasPurpose`; recovery uses a real ERC-734 identity.
+ investorOnchainId = new OnchainIdMock();
+ // Hoisted: an external call in an argument position would consume the vm.prank below.
+ registrarRole = registry.IDENTITY_REGISTRAR_ROLE();
+
+ // The operator maintains the whitelist...
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registrarRole, AGENT);
+ // ...and the TOKEN itself must hold the role, because `recoveryAddress` makes the token
+ // call registerIdentity/deleteIdentity.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registrarRole, address(token));
+
+ vm.prank(AGENT);
+ registry.registerIdentity(INVESTOR, address(0), COUNTRY_CH);
+ vm.prank(AGENT);
+ registry.registerIdentity(INVESTOR2, address(0), COUNTRY_CH);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MINT
+ //////////////////////////////////////////////////////////////*/
+
+ function testMint_ToVerifiedInvestor() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 100);
+ }
+
+ function testMint_ToUnverifiedRecipientReverts() public {
+ vm.prank(AGENT);
+ vm.expectRevert("Identity is not verified.");
+ token.mint(OUTSIDER, 100);
+ }
+
+ /**
+ * @notice `address(0)` must never be verified, so the registry can never be tricked into
+ * authorising a mint to the zero address.
+ */
+ function testMint_ToZeroAddressReverts() public {
+ assertFalse(registry.isVerified(ZERO_ADDRESS));
+ vm.prank(AGENT);
+ vm.expectRevert("Identity is not verified.");
+ token.mint(ZERO_ADDRESS, 100);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransfer_BetweenVerifiedInvestors() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ assertTrue(token.transfer(INVESTOR2, 40));
+ assertEq(token.balanceOf(INVESTOR2), 40);
+ }
+
+ function testTransfer_ToUnverifiedRecipientReverts() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(INVESTOR);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transfer(OUTSIDER, 40);
+ }
+
+ /**
+ * @notice ERC-3643 screens only the RECEIVER. A de-listed holder can still send, which is what
+ * lets a lapsed investor exit their position.
+ */
+ function testTransfer_DeListedSenderCanStillExit() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ registry.deleteIdentity(INVESTOR);
+ assertFalse(registry.isVerified(INVESTOR));
+
+ vm.prank(INVESTOR);
+ assertTrue(token.transfer(INVESTOR2, 100));
+ assertEq(token.balanceOf(INVESTOR2), 100);
+ }
+
+ function testTransferFrom_ChecksTheRecipientOnly() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(OUTSIDER);
+ assertTrue(token.transferFrom(INVESTOR, INVESTOR2, 30));
+
+ vm.prank(OUTSIDER);
+ vm.expectRevert("Transfer not possible");
+ // forge-lint: disable-next-line(erc20-unchecked-transfer)
+ token.transferFrom(INVESTOR, OUTSIDER, 30);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FORCED TRANSFER
+ //////////////////////////////////////////////////////////////*/
+
+ function testForcedTransfer_ToVerifiedRecipient() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ assertTrue(token.forcedTransfer(INVESTOR, INVESTOR2, 60));
+ assertEq(token.balanceOf(INVESTOR2), 60);
+ }
+
+ /**
+ * @notice `forcedTransfer` bypasses freezes but NOT the registry: the recipient must be
+ * verified even when an agent forces the move.
+ */
+ function testForcedTransfer_ToUnverifiedRecipientReverts() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert("Transfer not possible");
+ token.forcedTransfer(INVESTOR, OUTSIDER, 60);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ BURN
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `burn` makes no registry call at all, so a de-listed holder can still be burned out.
+ */
+ function testBurn_WorksEvenForADeListedHolder() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ registry.deleteIdentity(INVESTOR);
+
+ vm.prank(AGENT);
+ token.burn(INVESTOR, 100);
+ assertEq(token.balanceOf(INVESTOR), 0);
+ assertEq(token.totalSupply(), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ RECOVERY ADDRESS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The replacement wallet is whitelisted BY THE TOKEN during recovery, exactly as in
+ * stock ERC-3643 — it must not be pre-registered, or step 3 would hit the duplicate
+ * guard. The ONCHAINID vouching for the wallet is supplied by the agent.
+ */
+ function testRecoveryAddress_MovesThePositionToTheNewWallet() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ investorOnchainId.addWalletKey(NEW_WALLET, 1);
+ assertFalse(registry.isVerified(NEW_WALLET), "not pre-registered");
+
+ vm.prank(AGENT);
+ assertTrue(token.recoveryAddress(INVESTOR, NEW_WALLET, address(investorOnchainId)));
+
+ assertEq(token.balanceOf(NEW_WALLET), 100, "position moved");
+ assertEq(token.balanceOf(INVESTOR), 0);
+ assertFalse(registry.isVerified(INVESTOR), "lost wallet de-registered by the token");
+ assertTrue(registry.isVerified(NEW_WALLET), "new wallet registered by the token");
+ }
+
+ /**
+ * @notice Recovery is gated on the ONCHAINID vouching for the replacement wallet.
+ */
+ function testRecoveryAddress_RevertsWhenIdentityDoesNotVouchForTheWallet() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+
+ vm.prank(AGENT);
+ vm.expectRevert("Recovery not possible");
+ token.recoveryAddress(INVESTOR, NEW_WALLET, address(investorOnchainId));
+ }
+
+ /**
+ * @notice Without the registrar role the token cannot complete recovery: `registerIdentity` is
+ * called BY THE TOKEN.
+ */
+ function testRecoveryAddress_RevertsWhenTokenLacksTheRegistrarRole() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+ investorOnchainId.addWalletKey(NEW_WALLET, 1);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.revokeRole(registrarRole, address(token));
+
+ vm.prank(AGENT);
+ vm.expectRevert();
+ token.recoveryAddress(INVESTOR, NEW_WALLET, address(investorOnchainId));
+ }
+
+ /**
+ * @notice `recoveryAddress` reads `investorCountry(lostWallet)` and feeds it to
+ * `registerIdentity`. This registry keeps no country, so that round trip carries a
+ * constant 0 -- harmless, because the value is discarded on the way back in. The test
+ * pins that recovery still succeeds despite the registry having no identity data.
+ */
+ function testRecoveryAddress_SucceedsWithNoIdentityDataTracked() public {
+ vm.prank(AGENT);
+ token.mint(INVESTOR, 100);
+ investorOnchainId.addWalletKey(NEW_WALLET, 1);
+
+ assertEq(registry.investorCountry(INVESTOR), 0, "no country tracked");
+
+ vm.prank(AGENT);
+ assertTrue(token.recoveryAddress(INVESTOR, NEW_WALLET, address(investorOnchainId)));
+
+ assertEq(token.balanceOf(NEW_WALLET), 100);
+ assertEq(registry.investorCountry(NEW_WALLET), 0, "still none after recovery");
+ }
+}
diff --git a/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistUnit.t.sol b/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistUnit.t.sol
new file mode 100644
index 00000000..d55a60e8
--- /dev/null
+++ b/test/IdentityRegistryWhitelist/IdentityRegistryWhitelistUnit.t.sol
@@ -0,0 +1,146 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {
+ IdentityRegistryWhitelistInvariantStorage
+} from "src/registry/abstract/IdentityRegistryWhitelistInvariantStorage.sol";
+
+/**
+ * @title Unit tests for IdentityRegistryWhitelist
+ */
+contract IdentityRegistryWhitelistUnit is Test, HelperContract, IdentityRegistryWhitelistInvariantStorage {
+ address constant REGISTRAR = address(10);
+ uint16 constant COUNTRY_CH = 756;
+
+ IdentityRegistryWhitelist private registry;
+ bytes32 private registrarRole;
+
+ function setUp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry = new IdentityRegistryWhitelist(DEFAULT_ADMIN_ADDRESS);
+ registrarRole = registry.IDENTITY_REGISTRAR_ROLE();
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ registry.grantRole(registrarRole, REGISTRAR);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ REGISTRATION
+ //////////////////////////////////////////////////////////////*/
+
+ function testRegisterIdentity_VerifiesTheWallet() public {
+ assertFalse(registry.isVerified(ADDRESS1));
+
+ vm.expectEmit(true, true, false, false);
+ emit IdentityRegistered(ADDRESS1, ADDRESS3);
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+
+ assertTrue(registry.isVerified(ADDRESS1));
+ assertEq(registry.registeredIdentityCount(), 1);
+ }
+
+ /**
+ * @notice Duplicate registration reverts, matching ERC-3643's reference registry.
+ */
+ function testRegisterIdentity_RejectsDuplicates() public {
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+
+ vm.expectRevert(RuleAddressSet_AddressAlreadyListed.selector);
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS2, 250);
+
+ assertEq(registry.registeredIdentityCount(), 1);
+ }
+
+ /**
+ * @notice No identity data is kept: the ONCHAINID and country arguments are accepted so the
+ * ERC-3643 signature matches, then discarded. `investorCountry` is a constant 0.
+ */
+ function testNoIdentityDataIsStored() public {
+ assertEq(registry.investorCountry(ADDRESS1), 0, "unregistered");
+
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+
+ assertTrue(registry.isVerified(ADDRESS1));
+ assertEq(registry.investorCountry(ADDRESS1), 0, "country discarded, not stored");
+ }
+
+ /**
+ * @notice ERC-3643 defines `isVerified` as "is this a valid investor wallet"; `address(0)` is
+ * not a wallet, so it can never enter the registry.
+ */
+ function testRegisterIdentity_RejectsZeroAddress() public {
+ vm.expectRevert(RuleAddressSet_ZeroAddressNotAllowed.selector);
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ZERO_ADDRESS, ADDRESS3, COUNTRY_CH);
+
+ assertFalse(registry.isVerified(ZERO_ADDRESS));
+ }
+
+ function testRegisterIdentity_OnlyRegistrar() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DELETION
+ //////////////////////////////////////////////////////////////*/
+
+ function testDeleteIdentity_RemovesTheWallet() public {
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+
+ vm.expectEmit(true, false, false, false);
+ emit IdentityRemoved(ADDRESS1);
+ vm.prank(REGISTRAR);
+ registry.deleteIdentity(ADDRESS1);
+
+ assertFalse(registry.isVerified(ADDRESS1));
+ assertEq(registry.registeredIdentityCount(), 0);
+ }
+
+ function testDeleteIdentity_RevertsWhenNotRegistered() public {
+ vm.expectRevert(RuleAddressSet_AddressNotFound.selector);
+ vm.prank(REGISTRAR);
+ registry.deleteIdentity(ADDRESS1);
+ }
+
+ function testDeleteIdentity_OnlyRegistrar() public {
+ vm.prank(REGISTRAR);
+ registry.registerIdentity(ADDRESS1, ADDRESS3, COUNTRY_CH);
+
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ registry.deleteIdentity(ADDRESS1);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ NO INERT ROLES ON THE ABI
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The registry reuses `RuleAddressSetInternal` for storage, but must NOT advertise the
+ * address-list roles that gate `RuleAddressSet`'s public `addAddress` / `removeAddress`.
+ * It never enforces them -- registration is gated on `IDENTITY_REGISTRAR_ROLE` -- so
+ * exposing them would invite an operator to grant a privilege that authorises nothing,
+ * with no on-chain signal that the grant had no effect.
+ * @dev Static-called rather than asserted through the type system, because the whole point is
+ * that these selectors are absent: referencing them in Solidity would not compile.
+ */
+ function testDoesNotExposeInertAddressListRoles() public view {
+ (bool addFound,) = address(registry).staticcall(abi.encodeWithSignature("ADDRESS_LIST_ADD_ROLE()"));
+ (bool removeFound,) = address(registry).staticcall(abi.encodeWithSignature("ADDRESS_LIST_REMOVE_ROLE()"));
+ assertFalse(addFound, "ADDRESS_LIST_ADD_ROLE must not be on the registry ABI");
+ assertFalse(removeFound, "ADDRESS_LIST_REMOVE_ROLE must not be on the registry ABI");
+
+ // The role it does enforce is present.
+ (bool registrarFound,) = address(registry).staticcall(abi.encodeWithSignature("IDENTITY_REGISTRAR_ROLE()"));
+ assertTrue(registrarFound, "IDENTITY_REGISTRAR_ROLE must be exposed");
+ }
+}
diff --git a/test/Ownable2Step/Ownable2StepERC165Support.t.sol b/test/Ownable2Step/Ownable2StepERC165Support.t.sol
index 68c8607a..e9797a7b 100644
--- a/test/Ownable2Step/Ownable2StepERC165Support.t.sol
+++ b/test/Ownable2Step/Ownable2StepERC165Support.t.sol
@@ -11,10 +11,12 @@ import {RuleBlacklistOwnable2Step} from "src/rules/validation/deployment/RuleBla
import {RuleWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleWhitelistOwnable2Step.sol";
import {RuleWhitelistWrapperOwnable2Step} from "src/rules/validation/deployment/RuleWhitelistWrapperOwnable2Step.sol";
import {RuleSpenderWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleSpenderWhitelistOwnable2Step.sol";
+import {RuleReceiverWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol";
import {RuleERC2980Ownable2Step} from "src/rules/validation/deployment/RuleERC2980Ownable2Step.sol";
import {RuleSanctionsListOwnable2Step} from "src/rules/validation/deployment/RuleSanctionsListOwnable2Step.sol";
import {RuleIdentityRegistryOwnable2Step} from "src/rules/validation/deployment/RuleIdentityRegistryOwnable2Step.sol";
import {RuleMaxTotalSupplyOwnable2Step} from "src/rules/validation/deployment/RuleMaxTotalSupplyOwnable2Step.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
import {
RuleConditionalTransferLightOwnable2Step
} from "src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol";
@@ -31,12 +33,14 @@ contract Ownable2StepERC165SupportTest is Test {
RuleWhitelistOwnable2Step whitelist = new RuleWhitelistOwnable2Step(OWNER, address(0), false, false);
RuleWhitelistWrapperOwnable2Step wrapper = new RuleWhitelistWrapperOwnable2Step(OWNER, address(0), false, true);
RuleSpenderWhitelistOwnable2Step spenderWhitelist = new RuleSpenderWhitelistOwnable2Step(OWNER, address(0));
+ RuleReceiverWhitelistOwnable2Step receiverWhitelist = new RuleReceiverWhitelistOwnable2Step(OWNER, address(0));
RuleERC2980Ownable2Step erc2980 = new RuleERC2980Ownable2Step(OWNER, address(0), false);
RuleSanctionsListOwnable2Step sanctions =
new RuleSanctionsListOwnable2Step(OWNER, address(0), ISanctionsList(address(0)));
RuleIdentityRegistryOwnable2Step identity =
new RuleIdentityRegistryOwnable2Step(OWNER, address(0), false, false);
- RuleMaxTotalSupplyOwnable2Step maxSupply = new RuleMaxTotalSupplyOwnable2Step(OWNER, address(1), 1);
+ RuleMaxTotalSupplyOwnable2Step maxSupply =
+ new RuleMaxTotalSupplyOwnable2Step(OWNER, address(new TotalSupplyMock()), 1);
RuleConditionalTransferLightOwnable2Step conditional = new RuleConditionalTransferLightOwnable2Step(OWNER);
RuleConditionalTransferLightMultiTokenOwnable2Step conditionalMulti =
new RuleConditionalTransferLightMultiTokenOwnable2Step(OWNER);
@@ -45,6 +49,7 @@ contract Ownable2StepERC165SupportTest is Test {
_assertOwnable2StepInterfaces(address(whitelist));
_assertOwnable2StepInterfaces(address(wrapper));
_assertOwnable2StepInterfaces(address(spenderWhitelist));
+ _assertOwnable2StepInterfaces(address(receiverWhitelist));
_assertOwnable2StepInterfaces(address(erc2980));
_assertOwnable2StepInterfaces(address(sanctions));
_assertOwnable2StepInterfaces(address(identity));
diff --git a/test/RuleChainlinkPoR/CMTATIntegration.t.sol b/test/RuleChainlinkPoR/CMTATIntegration.t.sol
new file mode 100644
index 00000000..e720d4de
--- /dev/null
+++ b/test/RuleChainlinkPoR/CMTATIntegration.t.sol
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoR} from "src/rules/validation/deployment/RuleChainlinkPoR.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+
+/**
+ * @title End-to-end integration test: CMTAT + RuleEngine + RuleChainlinkPoR
+ * @notice Verifies that the Proof of Reserve backing is enforced through the full CMTAT call chain,
+ * and that transfers and burns stay unaffected.
+ */
+contract RuleChainlinkPoRCMTATIntegration is Test, HelperContract {
+ address constant MINTER = address(10);
+ uint8 constant FEED_DECIMALS = 8;
+ uint256 constant ONE_DAY = 1 days;
+
+ AggregatorV3Mock private feed;
+ RuleChainlinkPoR private rule;
+ uint8 private cmtatDecimals;
+
+ function setUp() public {
+ vm.warp(1_000_000);
+ cmtatDeployment = new CMTATDeployment();
+ cmtatContract = cmtatDeployment.cmtat();
+ cmtatDecimals = cmtatContract.decimals();
+
+ // 1_000 reserve units, reported with 8 decimals.
+ feed = new AggregatorV3Mock(FEED_DECIMALS, 1000 * 1e8);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ ruleEngineMock = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, address(cmtatContract));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(cmtatContract), cmtatDecimals, AggregatorV3Interface(address(feed)), ONE_DAY
+ );
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ ruleEngineMock.addRule(rule);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.setRuleEngine(ruleEngineMock);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.grantRole(keccak256("MINTER_ROLE"), MINTER);
+ }
+
+ /**
+ * @notice Reserve expressed in the token's own units.
+ */
+ function _reserveInTokenUnits() private view returns (uint256) {
+ return 1000 * (10 ** uint256(cmtatDecimals));
+ }
+
+ function testMintSucceedsWhenBackedByReserves() public {
+ uint256 amount = _reserveInTokenUnits();
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, amount);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), amount);
+ }
+
+ function testMintRevertsWhenExceedingReserves() public {
+ uint256 amount = _reserveInTokenUnits() + 1;
+
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, amount);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 0);
+ }
+
+ function testSecondMintIsCheckedAgainstTheUpdatedSupply() public {
+ uint256 half = _reserveInTokenUnits() / 2;
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, half);
+
+ // The remaining headroom is exactly `half`.
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, half);
+
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, 1);
+ }
+
+ function testMintUnlockedByAReserveIncrease() public {
+ uint256 amount = _reserveInTokenUnits();
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, amount);
+
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, amount);
+
+ // Reserves double: the previously rejected mint now goes through.
+ feed.setAnswer(2000 * 1e8);
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, amount);
+
+ assertEq(cmtatContract.balanceOf(ADDRESS1), 2 * amount);
+ }
+
+ function testStaleFeedBlocksMintButNotTransfersOrBurns() public {
+ uint256 amount = _reserveInTokenUnits();
+
+ vm.prank(MINTER);
+ cmtatContract.mint(ADDRESS1, amount);
+
+ vm.warp(block.timestamp + ONE_DAY + 1);
+ assertEq(ruleEngineMock.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_FEED_STALE);
+
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS1, 1);
+
+ // Holders can still move and burn their tokens while the feed is stale.
+ vm.prank(ADDRESS1);
+ assertTrue(cmtatContract.transfer(ADDRESS2, amount / 2));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.grantRole(keccak256("BURNER_ROLE"), DEFAULT_ADMIN_ADDRESS);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.burn(ADDRESS1, amount / 2, "burn while stale");
+ }
+
+ function testRuleEngineViewsReflectTheReserveLimit() public {
+ uint256 amount = _reserveInTokenUnits();
+
+ assertEq(ruleEngineMock.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, amount), TRANSFER_OK);
+ assertTrue(ruleEngineMock.canTransfer(ZERO_ADDRESS, ADDRESS1, amount));
+
+ assertEq(ruleEngineMock.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, amount + 1), CODE_RESERVES_EXCEEDED);
+ assertFalse(ruleEngineMock.canTransfer(ZERO_ADDRESS, ADDRESS1, amount + 1));
+ }
+
+ function testMessageIsResolvedThroughTheRuleEngine() public view {
+ assertEq(ruleEngineMock.messageForTransferRestriction(CODE_RESERVES_EXCEEDED), TEXT_RESERVES_EXCEEDED);
+ assertEq(ruleEngineMock.messageForTransferRestriction(CODE_RESERVES_FEED_STALE), TEXT_RESERVES_FEED_STALE);
+ assertEq(
+ ruleEngineMock.messageForTransferRestriction(CODE_RESERVES_ANSWER_INVALID), TEXT_RESERVES_ANSWER_INVALID
+ );
+ }
+}
diff --git a/test/RuleChainlinkPoR/Ownable/RuleChainlinkPoROwnable2Step.t.sol b/test/RuleChainlinkPoR/Ownable/RuleChainlinkPoROwnable2Step.t.sol
new file mode 100644
index 00000000..da3acef2
--- /dev/null
+++ b/test/RuleChainlinkPoR/Ownable/RuleChainlinkPoROwnable2Step.t.sol
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol";
+import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol";
+import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol";
+import {HelperContract} from "../../HelperContract.sol";
+import {Ownable2StepTestBase, IOwnable2StepLike} from "../../utils/Ownable2StepTestBase.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoROwnable2Step} from "src/rules/validation/deployment/RuleChainlinkPoROwnable2Step.sol";
+import {TotalSupplyDecimalsMock} from "src/mocks/TotalSupplyDecimalsMock.sol";
+
+contract RuleChainlinkPoROwnable2StepTest is Ownable2StepTestBase {
+ function _deployOwnable2Step() internal override returns (IOwnable2StepLike, address) {
+ address ownerAddr = WHITELIST_OPERATOR_ADDRESS;
+ TotalSupplyDecimalsMock token = new TotalSupplyDecimalsMock(18);
+ AggregatorV3Mock feed = new AggregatorV3Mock(8, 1000 * 1e8);
+ RuleChainlinkPoROwnable2Step rule = new RuleChainlinkPoROwnable2Step(
+ ownerAddr, address(token), 18, AggregatorV3Interface(address(feed)), 1 days
+ );
+ return (IOwnable2StepLike(address(rule)), ownerAddr);
+ }
+}
+
+contract RuleChainlinkPoROwnable2StepAccessControl is Test, HelperContract {
+ error OwnableUnauthorizedAccount(address account);
+
+ RuleChainlinkPoROwnable2Step private rule;
+ TotalSupplyDecimalsMock private token;
+ AggregatorV3Mock private feed;
+
+ function setUp() public {
+ vm.warp(1_000_000);
+ token = new TotalSupplyDecimalsMock(18);
+ feed = new AggregatorV3Mock(8, 1000 * 1e8);
+ rule = new RuleChainlinkPoROwnable2Step(
+ WHITELIST_OPERATOR_ADDRESS, address(token), 18, AggregatorV3Interface(address(feed)), 1 days
+ );
+ }
+
+ function testOwnerCanManageConfiguration() public {
+ AggregatorV3Mock newFeed = new AggregatorV3Mock(18, 500 * 1e18);
+ TotalSupplyDecimalsMock newToken = new TotalSupplyDecimalsMock(6);
+
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(newFeed)));
+ assertEq(address(rule.reservesFeed()), address(newFeed));
+
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ rule.setTokenMetadata(address(newToken), 6);
+ assertEq(address(rule.tokenContract()), address(newToken));
+
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ rule.setMaxStalenessSeconds(3600);
+ assertEq(rule.maxStalenessSeconds(), 3600);
+ }
+
+ function testNonOwnerCannotManageConfiguration() public {
+ vm.expectRevert(abi.encodeWithSelector(OwnableUnauthorizedAccount.selector, ATTACKER));
+ vm.prank(ATTACKER);
+ rule.setReservesFeed(AggregatorV3Interface(address(feed)));
+
+ vm.expectRevert(abi.encodeWithSelector(OwnableUnauthorizedAccount.selector, ATTACKER));
+ vm.prank(ATTACKER);
+ rule.setTokenMetadata(address(token), 18);
+
+ vm.expectRevert(abi.encodeWithSelector(OwnableUnauthorizedAccount.selector, ATTACKER));
+ vm.prank(ATTACKER);
+ rule.setMaxStalenessSeconds(1);
+ }
+
+ function testSupportsInterface() public view {
+ assertTrue(rule.supportsInterface(type(IERC165).interfaceId), "IERC165");
+ assertTrue(rule.supportsInterface(RuleInterfaceId.IRULE_INTERFACE_ID), "IRule");
+ assertTrue(rule.supportsInterface(RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID), "IRuleEngine");
+ assertTrue(rule.supportsInterface(ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID), "IERC1404Extend");
+ assertFalse(rule.supportsInterface(bytes4(0xdeadbeef)), "unknown interface");
+ }
+
+ function testRestrictionLogicMatchesTheAccessControlVariant() public {
+ token.setTotalSupply(900 * 1e18);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 100 * 1e18), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 100 * 1e18 + 1), CODE_RESERVES_EXCEEDED);
+ }
+}
diff --git a/test/RuleChainlinkPoR/RuleChainlinkPoRDecimals.t.sol b/test/RuleChainlinkPoR/RuleChainlinkPoRDecimals.t.sol
new file mode 100644
index 00000000..ee469e16
--- /dev/null
+++ b/test/RuleChainlinkPoR/RuleChainlinkPoRDecimals.t.sol
@@ -0,0 +1,263 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoR} from "src/rules/validation/deployment/RuleChainlinkPoR.sol";
+import {TotalSupplyDecimalsMock} from "src/mocks/TotalSupplyDecimalsMock.sol";
+
+/**
+ * @title Decimal-scaling tests for RuleChainlinkPoR
+ * @notice `tokenDecimals` is used in exactly two places: the bound check in `_setTokenMetadata`
+ * and `_scaleReserve`. These tests pin the scaling behaviour across the realistic
+ * combinations of token decimals (0 for CMTAT equity, 6 for USDC-style, 18 for ERC-20
+ * default) and feed decimals (8 and 18 are what Chainlink actually publishes), including
+ * the degenerate ends of the accepted ranges.
+ *
+ * The `tokenDecimals == 0` case matters because CMTAT equity tokens report 0 decimals,
+ * which Chainlink's own `SecureMintPolicy` rejects. It is the case where `_scaleReserve`
+ * divides by the largest possible factor, so it is where truncation bites hardest.
+ */
+contract RuleChainlinkPoRDecimals is Test, HelperContract {
+ uint256 constant ONE_DAY = 1 days;
+
+ function setUp() public {
+ vm.warp(1_000_000);
+ }
+
+ /**
+ * @notice Deploys a rule over a token with `tokenDecimals` and a feed with `feedDecimals`
+ * reporting `answer`.
+ */
+ function _deploy(uint8 tokenDecimals, uint8 feedDecimals, int256 answer)
+ private
+ returns (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token)
+ {
+ token = new TotalSupplyDecimalsMock(tokenDecimals);
+ AggregatorV3Mock feed = new AggregatorV3Mock(feedDecimals, answer);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(token), tokenDecimals, AggregatorV3Interface(address(feed)), ONE_DAY
+ );
+ }
+
+ /**
+ * @notice Asserts the backed supply for a given decimals pairing, and that the rule enforces
+ * exactly that boundary on the mint path.
+ */
+ function _assertBackedSupply(uint8 tokenDecimals, uint8 feedDecimals, int256 answer, uint256 expected) private {
+ (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token) = _deploy(tokenDecimals, feedDecimals, answer);
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK, "feed answer should be usable");
+ assertEq(backedSupply, expected, "backed supply mismatch");
+
+ // The boundary is enforced, not merely reported.
+ token.setTotalSupply(0);
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, expected), TRANSFER_OK, "limit must be mintable"
+ );
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, expected + 1),
+ CODE_RESERVES_EXCEEDED,
+ "one over the limit must be rejected"
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ 1_000 WHOLE UNITS ACROSS TOKEN / FEED DECIMALS
+ //////////////////////////////////////////////////////////////*/
+
+ function testScaling_Token18_Feed8() public {
+ _assertBackedSupply(18, 8, 1000 * 1e8, 1000 * 1e18);
+ }
+
+ function testScaling_Token6_Feed8() public {
+ _assertBackedSupply(6, 8, 1000 * 1e8, 1000 * 1e6);
+ }
+
+ function testScaling_Token0_Feed8() public {
+ _assertBackedSupply(0, 8, 1000 * 1e8, 1000);
+ }
+
+ function testScaling_Token18_Feed18() public {
+ _assertBackedSupply(18, 18, 1000 * 1e18, 1000 * 1e18);
+ }
+
+ function testScaling_Token6_Feed18() public {
+ _assertBackedSupply(6, 18, int256(1000 * 1e18), 1000 * 1e6);
+ }
+
+ function testScaling_Token0_Feed18() public {
+ _assertBackedSupply(0, 18, int256(1000 * 1e18), 1000);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DEGENERATE DECIMAL ENDS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Both sides at 0 decimals: whole units on the feed, whole tokens on the token.
+ * `_scaleReserve` takes the `to == from` short circuit and passes the answer through.
+ */
+ function testScaling_Token0_Feed0_IsIdentity() public {
+ _assertBackedSupply(0, 0, 1000, 1000);
+ }
+
+ /**
+ * @notice A 0-decimals feed with an 18-decimals token exercises the scale-up branch, which is
+ * unreachable when the token has 0 decimals.
+ */
+ function testScaling_Token18_Feed0_ScalesUp() public {
+ _assertBackedSupply(18, 0, 1000, 1000 * 1e18);
+ }
+
+ /**
+ * @notice The widest accepted spread: a 36-decimals feed against a 0-decimals token divides by
+ * 10 ** 36. This is the largest divisor the rule can ever build and it must not revert.
+ */
+ function testScaling_Token0_FeedMax_DoesNotRevert() public {
+ uint8 maxFeedDecimals = 36;
+ _assertBackedSupply(0, maxFeedDecimals, int256(1000 * (10 ** 36)), 1000);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TRUNCATION IS CONSERVATIVE
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Scaling down truncates, and truncation always rounds the backed supply DOWN. That is
+ * the safe direction for a reserve rule: it can under-mint, never over-mint.
+ */
+ function testTruncation_Token0_DropsTheFractionalReserve() public {
+ // 1000.99999999 units reported with 8 decimals backs only 1000 whole tokens.
+ _assertBackedSupply(0, 8, 1000 * 1e8 + 99_999_999, 1000);
+ }
+
+ function testTruncation_Token6_DropsSubUnitDigits() public {
+ // 1000.00000001 with 8 feed decimals: the two digits below 1e-6 are dropped.
+ _assertBackedSupply(6, 8, 1000 * 1e8 + 1, 1000 * 1e6);
+ }
+
+ /**
+ * @notice With 0-decimals tokens, reserves below one whole unit back nothing at all, so every
+ * non-zero mint is rejected. A zero-value mint still passes: it is trivially backed.
+ */
+ function testTruncation_Token0_SubUnitReservesBackNothing() public {
+ (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token) = _deploy(0, 8, 99_999_999); // 0.99999999
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ assertEq(backedSupply, 0);
+
+ token.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_EXCEEDED);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 0), TRANSFER_OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ENFORCEMENT AGAINST A NON-ZERO SUPPLY
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The headroom calculation is decimals-agnostic: whatever the scaling, a mint is
+ * allowed exactly up to `backedSupply - totalSupply`.
+ */
+ function testHeadroomIsCorrectAtEveryTokenDecimals() public {
+ uint8[3] memory tokenDecimalsCases = [uint8(0), 6, 18];
+
+ for (uint256 i = 0; i < tokenDecimalsCases.length; i++) {
+ uint8 tokenDecimals = tokenDecimalsCases[i];
+ (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token) = _deploy(tokenDecimals, 8, 1000 * 1e8);
+
+ uint256 unit = 10 ** uint256(tokenDecimals);
+ uint256 backed = 1000 * unit;
+ uint256 supply = 400 * unit;
+ token.setTotalSupply(supply);
+
+ uint256 headroom = backed - supply;
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, headroom),
+ TRANSFER_OK,
+ "exact headroom must be mintable"
+ );
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, headroom + 1),
+ CODE_RESERVES_EXCEEDED,
+ "one over the headroom must be rejected"
+ );
+ }
+ }
+
+ /**
+ * @notice A supply already above the backed amount rejects even a 1-unit mint, at any decimals.
+ */
+ function testSupplyAboveReservesRejectsAtEveryTokenDecimals() public {
+ uint8[3] memory tokenDecimalsCases = [uint8(0), 6, 18];
+
+ for (uint256 i = 0; i < tokenDecimalsCases.length; i++) {
+ uint8 tokenDecimals = tokenDecimalsCases[i];
+ (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token) = _deploy(tokenDecimals, 8, 1000 * 1e8);
+
+ token.setTotalSupply(1000 * (10 ** uint256(tokenDecimals)) + 1);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_EXCEEDED);
+ }
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FUZZ
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Cross-checks the branchy `_scaleReserve` against its mathematical definition,
+ * `answer * 10**tokenDecimals / 10**feedDecimals`, computed with `Math.mulDiv` so the
+ * reference carries the full intermediate product. `answer` is bounded away from the
+ * saturation regime, which `testScaling_SaturatesInsteadOfOverflowing` covers separately.
+ */
+ function testFuzz_ScalingMatchesTheMathematicalDefinition(uint8 tokenDecimals, uint8 feedDecimals, uint256 answer)
+ public
+ {
+ tokenDecimals = uint8(bound(tokenDecimals, 0, 18));
+ feedDecimals = uint8(bound(feedDecimals, 0, 36));
+ answer = bound(answer, 0, 1e30);
+
+ // `answer` is bounded to 1e30 above, far inside int256.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ (RuleChainlinkPoR rule,) = _deploy(tokenDecimals, feedDecimals, int256(answer));
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ assertEq(backedSupply, Math.mulDiv(answer, 10 ** uint256(tokenDecimals), 10 ** uint256(feedDecimals)));
+ }
+
+ /**
+ * @notice Whatever the decimals pairing, the read path returns a code and never reverts — the
+ * ERC-1404 / ERC-3643 MUST-NOT-revert contract holds across the whole decimals domain.
+ */
+ function testFuzz_ReadPathNeverRevertsAtAnyDecimals(
+ uint8 tokenDecimals,
+ uint8 feedDecimals,
+ int256 answer,
+ uint256 currentSupply,
+ uint256 value
+ ) public {
+ tokenDecimals = uint8(bound(tokenDecimals, 0, 18));
+ feedDecimals = uint8(bound(feedDecimals, 0, 36));
+
+ (RuleChainlinkPoR rule, TotalSupplyDecimalsMock token) = _deploy(tokenDecimals, feedDecimals, answer);
+ token.setTotalSupply(currentSupply);
+
+ uint8 restrictionCode = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, value);
+
+ if (answer < 0) {
+ assertEq(restrictionCode, CODE_RESERVES_ANSWER_INVALID);
+ return;
+ }
+ (, uint256 backedSupply) = rule.maxBackedSupply();
+ bool exceeds = currentSupply > backedSupply || value > backedSupply - currentSupply;
+ assertEq(restrictionCode, exceeds ? CODE_RESERVES_EXCEEDED : TRANSFER_OK);
+ }
+}
diff --git a/test/RuleChainlinkPoR/RuleChainlinkPoRUnit.t.sol b/test/RuleChainlinkPoR/RuleChainlinkPoRUnit.t.sol
new file mode 100644
index 00000000..e7e2d386
--- /dev/null
+++ b/test/RuleChainlinkPoR/RuleChainlinkPoRUnit.t.sol
@@ -0,0 +1,656 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {RuleChainlinkPoR} from "src/rules/validation/deployment/RuleChainlinkPoR.sol";
+import {TotalSupplyDecimalsMock} from "src/mocks/TotalSupplyDecimalsMock.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
+
+/**
+ * @title Unit tests for RuleChainlinkPoR
+ */
+contract RuleChainlinkPoRUnit is Test, HelperContract {
+ uint8 constant TOKEN_DECIMALS = 18;
+ uint8 constant FEED_DECIMALS = 8;
+ // 1_000 reserve units reported with 8 decimals.
+ int256 constant RESERVE_1000 = 1000 * 1e8;
+ // The same 1_000 units expressed with the token's 18 decimals.
+ uint256 constant RESERVE_1000_SCALED = 1000 * 1e18;
+ uint256 constant ONE_DAY = 1 days;
+
+ TotalSupplyDecimalsMock private token;
+ AggregatorV3Mock private feed;
+ RuleChainlinkPoR private rule;
+
+ function setUp() public {
+ // Move away from block.timestamp == 1 so staleness arithmetic is meaningful.
+ vm.warp(1_000_000);
+ token = new TotalSupplyDecimalsMock(TOKEN_DECIMALS);
+ feed = new AggregatorV3Mock(FEED_DECIMALS, RESERVE_1000);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(token), TOKEN_DECIMALS, AggregatorV3Interface(address(feed)), ONE_DAY
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ CONSTRUCTOR
+ //////////////////////////////////////////////////////////////*/
+
+ function testConstructor_StoresConfiguration() public view {
+ assertEq(address(rule.reservesFeed()), address(feed));
+ assertEq(address(rule.tokenContract()), address(token));
+ assertEq(rule.feedDecimals(), FEED_DECIMALS);
+ assertEq(rule.tokenDecimals(), TOKEN_DECIMALS);
+ assertEq(rule.maxStalenessSeconds(), ONE_DAY);
+ }
+
+ function testConstructor_RevertsOnZeroFeed() public {
+ vm.expectRevert(RuleChainlinkPoR_FeedAddressZeroNotAllowed.selector);
+ new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(token), TOKEN_DECIMALS, AggregatorV3Interface(ZERO_ADDRESS), ONE_DAY
+ );
+ }
+
+ function testConstructor_RevertsOnNonContractFeed() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_FeedIsNotAContract.selector, ADDRESS1));
+ new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(token), TOKEN_DECIMALS, AggregatorV3Interface(ADDRESS1), ONE_DAY
+ );
+ }
+
+ function testConstructor_RevertsOnZeroToken() public {
+ vm.expectRevert(RuleChainlinkPoR_TokenAddressZeroNotAllowed.selector);
+ new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, TOKEN_DECIMALS, AggregatorV3Interface(address(feed)), ONE_DAY
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FEED CONFIGURATION
+ //////////////////////////////////////////////////////////////*/
+
+ function testSetReservesFeed_UpdatesFeedAndDecimals() public {
+ // RESERVE_1000_SCALED is the constant 1000 * 1e18, far inside int256.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ AggregatorV3Mock newFeed = new AggregatorV3Mock(18, int256(RESERVE_1000_SCALED));
+
+ vm.expectEmit(true, false, false, true);
+ emit ReservesFeedUpdated(address(newFeed), 18);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(newFeed)));
+
+ assertEq(address(rule.reservesFeed()), address(newFeed));
+ assertEq(rule.feedDecimals(), 18);
+ }
+
+ function testSetReservesFeed_RevertsWhenDecimalsUnavailable() public {
+ AggregatorV3Mock brokenFeed = new AggregatorV3Mock(FEED_DECIMALS, RESERVE_1000);
+ brokenFeed.setRevertOnDecimals(true);
+
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_FeedDecimalsUnavailable.selector, address(brokenFeed)));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(brokenFeed)));
+ }
+
+ function testSetReservesFeed_RevertsWhenDecimalsTooLarge() public {
+ uint8 tooManyDecimals = rule.MAX_FEED_DECIMALS() + 1;
+ AggregatorV3Mock wideFeed = new AggregatorV3Mock(tooManyDecimals, RESERVE_1000);
+
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_FeedDecimalsTooLarge.selector, tooManyDecimals));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(wideFeed)));
+ }
+
+ function testSetReservesFeed_OnlyAdmin() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ rule.setReservesFeed(AggregatorV3Interface(address(feed)));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TOKEN CONFIGURATION
+ //////////////////////////////////////////////////////////////*/
+
+ function testSetTokenMetadata_UpdatesTokenAndDecimals() public {
+ TotalSupplyDecimalsMock newToken = new TotalSupplyDecimalsMock(6);
+
+ vm.expectEmit(true, false, false, true);
+ emit TokenMetadataUpdated(address(newToken), 6);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(newToken), 6);
+
+ assertEq(address(rule.tokenContract()), address(newToken));
+ assertEq(rule.tokenDecimals(), 6);
+ }
+
+ function testSetTokenMetadata_AcceptsTokenWithoutDecimals() public {
+ TotalSupplyMock plainToken = new TotalSupplyMock();
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(plainToken), 6);
+
+ assertEq(address(rule.tokenContract()), address(plainToken));
+ assertEq(rule.tokenDecimals(), 6);
+ }
+
+ function testSetTokenMetadata_RevertsOnDecimalsMismatch() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_TokenDecimalsMismatch.selector, 6, TOKEN_DECIMALS));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(token), 6);
+ }
+
+ /**
+ * @notice A 0-decimals token is valid: CMTAT equity tokens commonly report 0 decimals.
+ */
+ function testSetTokenMetadata_AcceptsZeroDecimals() public {
+ TotalSupplyDecimalsMock shareToken = new TotalSupplyDecimalsMock(0);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(shareToken), 0);
+ assertEq(rule.tokenDecimals(), 0);
+
+ // 1000 reserve units with 8 feed decimals scale down to 1000 whole tokens.
+ shareToken.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1000), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1001), CODE_RESERVES_EXCEEDED);
+ }
+
+ function testSetTokenMetadata_RevertsAboveMaxDecimals() public {
+ uint8 tooManyDecimals = rule.MAX_TOKEN_DECIMALS() + 1;
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_InvalidTokenDecimals.selector, tooManyDecimals));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(token), tooManyDecimals);
+ }
+
+ function testSetTokenMetadata_RevertsOnZeroToken() public {
+ vm.expectRevert(RuleChainlinkPoR_TokenAddressZeroNotAllowed.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(ZERO_ADDRESS, TOKEN_DECIMALS);
+ }
+
+ function testSetTokenMetadata_OnlyAdmin() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ rule.setTokenMetadata(address(token), TOKEN_DECIMALS);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ STALENESS CONFIGURATION
+ //////////////////////////////////////////////////////////////*/
+
+ function testSetMaxStalenessSeconds_UpdatesThreshold() public {
+ vm.expectEmit(false, false, false, true);
+ emit MaxStalenessSecondsUpdated(3600);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMaxStalenessSeconds(3600);
+
+ assertEq(rule.maxStalenessSeconds(), 3600);
+ }
+
+ function testSetMaxStalenessSeconds_OnlyAdmin() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ rule.setMaxStalenessSeconds(3600);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ RESERVE ENFORCEMENT
+ //////////////////////////////////////////////////////////////*/
+
+ function testDetectRestriction_MintWithinReserves() public {
+ token.setTotalSupply(900 * 1e18);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 100 * 1e18);
+ assertEq(resUint8, TRANSFER_OK);
+ }
+
+ function testDetectRestriction_MintExceedingReserves() public {
+ token.setTotalSupply(900 * 1e18);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 100 * 1e18 + 1);
+ assertEq(resUint8, CODE_RESERVES_EXCEEDED);
+ }
+
+ function testDetectRestriction_MintWhenSupplyAlreadyAboveReserves() public {
+ token.setTotalSupply(RESERVE_1000_SCALED + 1);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, CODE_RESERVES_EXCEEDED);
+ }
+
+ function testDetectRestriction_TransferIsNeverGated() public {
+ token.setTotalSupply(RESERVE_1000_SCALED * 10);
+ resUint8 = rule.detectTransferRestriction(ADDRESS1, ADDRESS2, type(uint256).max);
+ assertEq(resUint8, TRANSFER_OK);
+ }
+
+ function testDetectRestriction_BurnIsNeverGated() public {
+ token.setTotalSupply(RESERVE_1000_SCALED * 10);
+ resUint8 = rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 5);
+ assertEq(resUint8, TRANSFER_OK);
+ }
+
+ function testDetectRestrictionFrom_UsesTheSameLogicAndIgnoresSpender() public {
+ token.setTotalSupply(900 * 1e18);
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 100 * 1e18), TRANSFER_OK);
+ assertEq(
+ rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 100 * 1e18 + 1), CODE_RESERVES_EXCEEDED
+ );
+ }
+
+ function testCanTransferAndCanTransferFrom() public {
+ token.setTotalSupply(900 * 1e18);
+ assertTrue(rule.canTransfer(ZERO_ADDRESS, ADDRESS1, 100 * 1e18));
+ assertFalse(rule.canTransfer(ZERO_ADDRESS, ADDRESS1, 100 * 1e18 + 1));
+ assertTrue(rule.canTransferFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 100 * 1e18));
+ assertFalse(rule.canTransferFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 100 * 1e18 + 1));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FEED FAILURE MODES
+ //////////////////////////////////////////////////////////////*/
+
+ function testDetectRestriction_StaleFeedBlocksMint() public {
+ vm.warp(block.timestamp + ONE_DAY + 1);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, CODE_RESERVES_FEED_STALE);
+ }
+
+ function testDetectRestriction_FeedExactlyAtThresholdIsAccepted() public {
+ vm.warp(block.timestamp + ONE_DAY);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, TRANSFER_OK);
+ }
+
+ function testDetectRestriction_StalenessCheckDisabledWithZeroThreshold() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMaxStalenessSeconds(0);
+
+ vm.warp(block.timestamp + 3650 days);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, TRANSFER_OK);
+ }
+
+ function testDetectRestriction_NegativeAnswerBlocksMint() public {
+ feed.setAnswer(-1);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, CODE_RESERVES_ANSWER_INVALID);
+ }
+
+ function testDetectRestriction_IncompleteRoundBlocksMint() public {
+ feed.setUpdatedAt(0);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, CODE_RESERVES_ANSWER_INVALID);
+ }
+
+ function testDetectRestriction_RevertingFeedBlocksMintWithoutReverting() public {
+ feed.setRevertOnLatestRoundData(true);
+ token.setTotalSupply(0);
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1);
+ assertEq(resUint8, CODE_RESERVES_FEED_UNAVAILABLE);
+ }
+
+ function testDetectRestriction_ZeroReserveBlocksAnyMint() public {
+ feed.setAnswer(0);
+ token.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_EXCEEDED);
+ // A zero-value mint is still backed.
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 0), TRANSFER_OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ TOKEN CONTRACT VALIDITY (F-2 REGRESSION)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice A non-contract token is rejected by an explicit check, not by the compiler's
+ * uncatchable extcodesize revert inside the `decimals()` probe.
+ */
+ function testTokenContract_RevertsOnNonContract() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_TokenIsNotAContract.selector, ADDRESS2));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(ADDRESS2, TOKEN_DECIMALS);
+ }
+
+ function testConstructor_RevertsOnNonContractToken() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleChainlinkPoR_TokenIsNotAContract.selector, ADDRESS2));
+ new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, ADDRESS2, TOKEN_DECIMALS, AggregatorV3Interface(address(feed)), ONE_DAY
+ );
+ }
+
+ /**
+ * @notice `totalSupply()` is mandatory. A contract that does not expose it is rejected at
+ * configuration rather than silently bricking the read path later.
+ */
+ function testTokenContract_RevertsWhenTotalSupplyMissing() public {
+ DecimalsOnlyMock decimalsOnly = new DecimalsOnlyMock();
+ vm.expectRevert(
+ abi.encodeWithSelector(RuleChainlinkPoR_TokenTotalSupplyUnavailable.selector, address(decimalsOnly))
+ );
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(decimalsOnly), TOKEN_DECIMALS);
+ }
+
+ /**
+ * @notice If the token breaks AFTER configuration, the read path must still return a code
+ * rather than revert -- the ERC-1404 views MUST NOT revert.
+ */
+ function testTokenContract_RevertingTotalSupplyYieldsACodeNotARevert() public {
+ token.setRevertOnTotalSupply(true);
+
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_TOTAL_SUPPLY_UNAVAILABLE, "must not revert"
+ );
+ assertFalse(rule.canTransfer(ZERO_ADDRESS, ADDRESS1, 1));
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 1), CODE_TOTAL_SUPPLY_UNAVAILABLE);
+
+ // Transfers and burns never read the supply, so they are unaffected.
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 1), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 1), TRANSFER_OK);
+ }
+
+ /**
+ * @notice The write path still reverts, carrying the new code.
+ */
+ function testTokenContract_TransferredRevertsWithTheSupplyCode() public {
+ token.setRevertOnTotalSupply(true);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleChainlinkPoR_InvalidTransfer.selector,
+ address(rule),
+ ZERO_ADDRESS,
+ ADDRESS1,
+ 1,
+ CODE_TOTAL_SUPPLY_UNAVAILABLE
+ )
+ );
+ rule.transferred(ZERO_ADDRESS, ADDRESS1, 1);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ LIVE FEED DECIMALS (F-1 REGRESSION)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The feed's decimals are read on every check, never cached. If they were cached, a
+ * feed that later reports MORE decimals would be read at the old, smaller scale and the
+ * reserves would be overstated by 10 ** delta -- silently authorising unbacked minting.
+ */
+ function testLiveDecimals_FeedRaisingItsDecimalsDoesNotOverstateReserves() public {
+ // Configured against an 8-decimals feed reporting 1000 units.
+ (, uint256 before) = rule.maxBackedSupply();
+ assertEq(before, RESERVE_1000_SCALED, "1000 tokens backed at 8 feed decimals");
+
+ // The feed migrates to 18 decimals, still reporting the same 1000 units.
+ feed.setDecimals(18);
+ // RESERVE_1000_SCALED is the constant 1000 * 1e18, far inside int256.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ feed.setAnswer(int256(RESERVE_1000_SCALED));
+
+ (, uint256 afterMigration) = rule.maxBackedSupply();
+ assertEq(afterMigration, RESERVE_1000_SCALED, "still exactly 1000 tokens backed");
+
+ // The mint boundary tracks the truth, not the stale scale.
+ token.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, RESERVE_1000_SCALED), TRANSFER_OK);
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, RESERVE_1000_SCALED + 1), CODE_RESERVES_EXCEEDED
+ );
+ }
+
+ /**
+ * @notice The mirror case: a feed lowering its decimals must not understate reserves either.
+ */
+ function testLiveDecimals_FeedLoweringItsDecimalsDoesNotUnderstateReserves() public {
+ feed.setDecimals(18);
+ // RESERVE_1000_SCALED is the constant 1000 * 1e18, far inside int256.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ feed.setAnswer(int256(RESERVE_1000_SCALED));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(feed)));
+ (, uint256 before) = rule.maxBackedSupply();
+ assertEq(before, RESERVE_1000_SCALED);
+
+ feed.setDecimals(8);
+ feed.setAnswer(RESERVE_1000);
+
+ (, uint256 afterMigration) = rule.maxBackedSupply();
+ assertEq(afterMigration, RESERVE_1000_SCALED, "still exactly 1000 tokens backed");
+ }
+
+ function testLiveDecimals_GetterTracksTheFeed() public {
+ assertEq(rule.feedDecimals(), FEED_DECIMALS);
+ feed.setDecimals(18);
+ assertEq(rule.feedDecimals(), 18, "getter must not serve a cached value");
+ }
+
+ /**
+ * @notice `decimals()` reverting at read time is a feed failure like any other: code 77, never
+ * a revert out of a MUST-NOT-revert view.
+ */
+ function testLiveDecimals_RevertingDecimalsBlocksMintWithoutReverting() public {
+ feed.setRevertOnDecimals(true);
+ token.setTotalSupply(0);
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, CODE_RESERVES_FEED_UNAVAILABLE);
+ assertEq(backedSupply, 0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_FEED_UNAVAILABLE);
+ }
+
+ /**
+ * @notice A feed that raises its decimals past MAX_FEED_DECIMALS after configuration would
+ * overflow the scaling exponent. The bound is re-checked at read time, so the view
+ * returns a code instead of reverting.
+ */
+ function testLiveDecimals_DecimalsAboveBoundBlockMintWithoutReverting() public {
+ feed.setDecimals(rule.MAX_FEED_DECIMALS() + 1);
+ token.setTotalSupply(0);
+
+ (uint8 code,) = rule.maxBackedSupply();
+ assertEq(code, CODE_RESERVES_FEED_UNAVAILABLE);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_FEED_UNAVAILABLE);
+ }
+
+ /**
+ * @notice Even the pathological end of the uint8 range must not revert the view.
+ */
+ function testLiveDecimals_MaxUint8DecimalsBlockMintWithoutReverting() public {
+ feed.setDecimals(type(uint8).max);
+ token.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_FEED_UNAVAILABLE);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ DECIMAL SCALING
+ //////////////////////////////////////////////////////////////*/
+
+ function testScaling_FeedDecimalsEqualTokenDecimals() public {
+ // RESERVE_1000_SCALED is the constant 1000 * 1e18, far inside int256.
+ // forge-lint: disable-next-line(unsafe-typecast)
+ AggregatorV3Mock sameDecimalsFeed = new AggregatorV3Mock(TOKEN_DECIMALS, int256(RESERVE_1000_SCALED));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setReservesFeed(AggregatorV3Interface(address(sameDecimalsFeed)));
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ assertEq(backedSupply, RESERVE_1000_SCALED);
+ }
+
+ function testScaling_FeedDecimalsAboveTokenDecimalsTruncates() public {
+ // Token with 6 decimals, feed with 8: the two least significant digits are dropped.
+ TotalSupplyDecimalsMock smallToken = new TotalSupplyDecimalsMock(6);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenMetadata(address(smallToken), 6);
+
+ // 1000.00000001 units reported with 8 decimals.
+ feed.setAnswer(RESERVE_1000 + 1);
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ assertEq(backedSupply, 1000 * 1e6);
+ }
+
+ function testScaling_SaturatesInsteadOfOverflowing() public {
+ feed.setAnswer(type(int256).max);
+
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ assertEq(backedSupply, type(uint256).max);
+
+ token.setTotalSupply(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, type(uint256).max), TRANSFER_OK);
+ }
+
+ function testMaxBackedSupply_ReportsFeedFailure() public {
+ feed.setRevertOnLatestRoundData(true);
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, CODE_RESERVES_FEED_UNAVAILABLE);
+ assertEq(backedSupply, 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ WRITE PATH
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferred_RevertsWhenMintNotBacked() public {
+ token.setTotalSupply(RESERVE_1000_SCALED);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleChainlinkPoR_InvalidTransfer.selector,
+ address(rule),
+ ZERO_ADDRESS,
+ ADDRESS1,
+ 1,
+ CODE_RESERVES_EXCEEDED
+ )
+ );
+ rule.transferred(ZERO_ADDRESS, ADDRESS1, 1);
+ }
+
+ function testTransferredFrom_RevertsWhenMintNotBacked() public {
+ token.setTotalSupply(RESERVE_1000_SCALED);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleChainlinkPoR_InvalidTransferFrom.selector,
+ address(rule),
+ ADDRESS3,
+ ZERO_ADDRESS,
+ ADDRESS1,
+ 1,
+ CODE_RESERVES_EXCEEDED
+ )
+ );
+ rule.transferred(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 1);
+ }
+
+ function testTransferred_DoesNotRevertWhenBacked() public {
+ token.setTotalSupply(900 * 1e18);
+ rule.transferred(ZERO_ADDRESS, ADDRESS1, 100 * 1e18);
+ rule.transferred(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 100 * 1e18);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ERC-1404 SURFACE
+ //////////////////////////////////////////////////////////////*/
+
+ function testCanReturnTransferRestrictionCode() public view {
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_RESERVES_EXCEEDED));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_RESERVES_FEED_STALE));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_RESERVES_ANSWER_INVALID));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_RESERVES_FEED_UNAVAILABLE));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_TOTAL_SUPPLY_UNAVAILABLE));
+ assertFalse(rule.canReturnTransferRestrictionCode(CODE_NONEXISTENT));
+ }
+
+ function testMessageForTransferRestriction() public view {
+ assertEq(rule.messageForTransferRestriction(CODE_RESERVES_EXCEEDED), TEXT_RESERVES_EXCEEDED);
+ assertEq(rule.messageForTransferRestriction(CODE_RESERVES_FEED_STALE), TEXT_RESERVES_FEED_STALE);
+ assertEq(rule.messageForTransferRestriction(CODE_RESERVES_ANSWER_INVALID), TEXT_RESERVES_ANSWER_INVALID);
+ assertEq(rule.messageForTransferRestriction(CODE_RESERVES_FEED_UNAVAILABLE), TEXT_RESERVES_FEED_UNAVAILABLE);
+ assertEq(rule.messageForTransferRestriction(CODE_TOTAL_SUPPLY_UNAVAILABLE), TEXT_TOTAL_SUPPLY_UNAVAILABLE);
+ assertEq(rule.messageForTransferRestriction(CODE_NONEXISTENT), TEXT_CODE_NOT_FOUND);
+ }
+
+ /**
+ * @notice The mock honours the whole `AggregatorV3Interface` surface, so a rule reading any of
+ * it sees Chainlink-shaped data.
+ */
+ function testFeedMockExposesTheFullAggregatorSurface() public {
+ assertEq(feed.description(), "AggregatorV3Mock");
+ assertEq(feed.version(), 3);
+
+ feed.setDecimals(18);
+ assertEq(feed.decimals(), 18);
+
+ (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt,) = feed.getRoundData(7);
+ assertEq(roundId, 7);
+ assertEq(answer, RESERVE_1000);
+ assertEq(startedAt, updatedAt);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ FUZZ
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Full-domain fuzz over supply and mint amount: the view must always return a code and
+ * never revert, including where `currentSupply + value` would overflow.
+ */
+ function testFuzz_MintBoundsNeverRevert(uint256 currentSupply, uint256 value, int256 answer) public {
+ feed.setAnswer(answer);
+ token.setTotalSupply(currentSupply);
+
+ resUint8 = rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, value);
+
+ if (answer < 0) {
+ assertEq(resUint8, CODE_RESERVES_ANSWER_INVALID);
+ return;
+ }
+ (uint8 code, uint256 backedSupply) = rule.maxBackedSupply();
+ assertEq(code, TRANSFER_OK);
+ bool exceeds = currentSupply > backedSupply || value > backedSupply - currentSupply;
+ assertEq(resUint8, exceeds ? CODE_RESERVES_EXCEEDED : TRANSFER_OK);
+ }
+
+ /**
+ * @notice The two feed-failure codes are deliberately distinct. `79` means no usable response
+ * could be obtained at all; `77` means a round WAS returned and its contents are
+ * unusable. Both block the mint, but they tell an operator different things: check feed
+ * liveness versus check that the configured address is really a PoR feed.
+ */
+ function testFeedFailureCodesDistinguishUnreachableFromUnusableAnswer() public {
+ token.setTotalSupply(0);
+
+ // No usable response -> 79.
+ feed.setRevertOnLatestRoundData(true);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_FEED_UNAVAILABLE);
+ feed.setRevertOnLatestRoundData(false);
+
+ // A response arrived, but the answer is unusable -> 77.
+ feed.setAnswer(-1);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_ANSWER_INVALID);
+
+ // Same for an incomplete round.
+ feed.setAnswer(RESERVE_1000);
+ feed.setUpdatedAt(0);
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_RESERVES_ANSWER_INVALID);
+ }
+}
+
+/**
+ * @notice Exposes `decimals()` but not `totalSupply()`: the shape that previously passed
+ * configuration and then reverted the read path.
+ */
+contract DecimalsOnlyMock {
+ function decimals() external pure returns (uint8) {
+ return 18;
+ }
+}
diff --git a/test/RuleConditionalTransferLight/Ownable/ConditionalTransferOwnable2StepBindingAuthorization.t.sol b/test/RuleConditionalTransferLight/Ownable/ConditionalTransferOwnable2StepBindingAuthorization.t.sol
new file mode 100644
index 00000000..4e22dc05
--- /dev/null
+++ b/test/RuleConditionalTransferLight/Ownable/ConditionalTransferOwnable2StepBindingAuthorization.t.sol
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {
+ RuleConditionalTransferLightOwnable2Step
+} from "src/rules/operation/RuleConditionalTransferLightOwnable2Step.sol";
+import {
+ RuleConditionalTransferLightMultiTokenOwnable2Step
+} from "src/rules/operation/RuleConditionalTransferLightMultiTokenOwnable2Step.sol";
+
+/**
+ * @notice Owner-only authorization on the two Ownable2Step conditional-transfer variants.
+ * @dev These hooks had no coverage before: the only test naming
+ * `RuleConditionalTransferLightMultiTokenOwnable2Step` was an ERC-165 support check, which never
+ * reaches an access-control path. Three concrete overrides were therefore unexercised —
+ * `_authorizeComplianceBindingChange` on the single-token variant, and `_onlyComplianceManager`
+ * plus `_authorizeTransferApproval` on the multi-token one.
+ *
+ * Note which entrypoint reaches which hook. `RuleConditionalTransferLightBase` overrides
+ * `bindToken` with its own `onlyComplianceManager` modifier, so on the single-token rule the
+ * only route to `_authorizeComplianceBindingChange` is the inherited `unbindToken`.
+ */
+contract ConditionalTransferOwnable2StepBindingAuthorizationTest is Test {
+ address constant OWNER = address(0xA11CE);
+ address constant ATTACKER = address(0xBAD);
+ address constant TOKEN = address(0x7);
+ address constant FROM = address(0x11);
+ address constant TO = address(0x12);
+ uint256 constant VALUE = 100;
+
+ RuleConditionalTransferLightOwnable2Step single;
+ RuleConditionalTransferLightMultiTokenOwnable2Step multi;
+
+ function setUp() public {
+ single = new RuleConditionalTransferLightOwnable2Step(OWNER);
+ multi = new RuleConditionalTransferLightMultiTokenOwnable2Step(OWNER);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ SINGLE TOKEN -- _authorizeComplianceBindingChange
+ //////////////////////////////////////////////////////////////*/
+
+ function testSingleUnbindTokenRejectsNonOwner() public {
+ vm.prank(OWNER);
+ single.bindToken(TOKEN);
+
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ single.unbindToken(TOKEN);
+ }
+
+ function testSingleUnbindTokenAllowsOwner() public {
+ vm.startPrank(OWNER);
+ single.bindToken(TOKEN);
+ assertTrue(single.isTokenBound(TOKEN));
+ single.unbindToken(TOKEN);
+ vm.stopPrank();
+
+ assertFalse(single.isTokenBound(TOKEN));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MULTI TOKEN -- _onlyComplianceManager
+ //////////////////////////////////////////////////////////////*/
+
+ function testMultiBindTokenRejectsNonOwner() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ multi.bindToken(TOKEN);
+ }
+
+ function testMultiBindTokenAllowsOwner() public {
+ vm.prank(OWNER);
+ multi.bindToken(TOKEN);
+ assertTrue(multi.isTokenBound(TOKEN));
+ }
+
+ function testMultiUnbindTokenRejectsNonOwner() public {
+ vm.prank(OWNER);
+ multi.bindToken(TOKEN);
+
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ multi.unbindToken(TOKEN);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MULTI TOKEN -- _authorizeTransferApproval
+ //////////////////////////////////////////////////////////////*/
+
+ function testMultiApproveTransferRejectsNonOwner() public {
+ vm.prank(OWNER);
+ multi.bindToken(TOKEN);
+
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ multi.approveTransfer(TOKEN, FROM, TO, VALUE);
+ }
+
+ function testMultiApproveTransferAllowsOwner() public {
+ vm.startPrank(OWNER);
+ multi.bindToken(TOKEN);
+ multi.approveTransfer(TOKEN, FROM, TO, VALUE);
+ vm.stopPrank();
+
+ assertEq(multi.approvedCount(TOKEN, FROM, TO, VALUE), 1);
+ }
+}
diff --git a/test/RuleConditionalTransferLight/RuleConditionalTransferLightUnit.t.sol b/test/RuleConditionalTransferLight/RuleConditionalTransferLightUnit.t.sol
index 882cacd2..f4998771 100644
--- a/test/RuleConditionalTransferLight/RuleConditionalTransferLightUnit.t.sol
+++ b/test/RuleConditionalTransferLight/RuleConditionalTransferLightUnit.t.sol
@@ -42,6 +42,28 @@ contract RuleConditionalTransferLightUnit is Test, HelperContract {
assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, 10), 1);
}
+ /**
+ * @notice The approval count carried by {TransferApproved} must be the post-increment value.
+ * @dev `approveTransfer` keeps the new count in a local rather than reading the slot back after
+ * storing it. The two are equivalent by construction, but nothing else in the suite asserts
+ * the event payload, so this pins it: a second approval of the same transfer must report 2,
+ * not 1 (pre-increment) and not 0 (uninitialised local).
+ */
+ function testApproveTransfer_EmitsPostIncrementCount() public {
+ vm.expectEmit(true, true, false, true);
+ emit TransferApproved(ADDRESS1, ADDRESS2, 10, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, 10);
+
+ vm.expectEmit(true, true, false, true);
+ emit TransferApproved(ADDRESS1, ADDRESS2, 10, 2);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, 10);
+
+ // The event and the getter must agree.
+ assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, 10), 2);
+ }
+
function testCancelTransferApproval_OnlyOperator() public {
vm.prank(DEFAULT_ADMIN_ADDRESS);
rule.approveTransfer(ADDRESS1, ADDRESS2, 10);
diff --git a/test/RuleConditionalTransferLight/TransferHashPreimage.t.sol b/test/RuleConditionalTransferLight/TransferHashPreimage.t.sol
new file mode 100644
index 00000000..d20e7a73
--- /dev/null
+++ b/test/RuleConditionalTransferLight/TransferHashPreimage.t.sol
@@ -0,0 +1,97 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {RuleConditionalTransferLight} from "src/rules/operation/RuleConditionalTransferLight.sol";
+import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol";
+
+/**
+ * @title TransferHashPreimage
+ * @notice Pins the approval-key preimage documented on `_transferHash` (`CLAUDE_ANALYSIS.md` F-4).
+ * @dev The key is a project-specific encoding — 32-byte words with each address LEFT-aligned and
+ * right-padded — which is neither `abi.encodePacked` nor `abi.encode`. Anyone deriving the
+ * storage slot off-chain (`eth_getStorageAt`, a state proof, an indexer reading storage rather
+ * than events) needs that layout, and getting it wrong fails *silently*: a wrong key reads `0`,
+ * which is indistinguishable from "no approval exists".
+ *
+ * These tests go through the contract's own public `approvalCounts(bytes32)` getter, so they
+ * verify the documented formulations against the real storage key rather than against a
+ * reimplementation of the assembly. If the encoding is ever changed, the NatSpec that tells
+ * integrators how to reproduce it becomes wrong — and this fails.
+ */
+contract TransferHashPreimage is Test, HelperContract {
+ address private constant FROM = address(0xA11CE);
+ address private constant TO = address(0xB0B);
+ address private constant TOKEN = address(0x7043);
+ uint256 private constant VALUE = 12_345;
+
+ RuleConditionalTransferLight private rule;
+ RuleConditionalTransferLightMultiToken private multi;
+
+ function setUp() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleConditionalTransferLight(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(ADDRESS3);
+ rule.approveTransfer(FROM, TO, VALUE);
+
+ multi = new RuleConditionalTransferLightMultiToken(DEFAULT_ADMIN_ADDRESS);
+ multi.bindToken(TOKEN);
+ multi.approveTransfer(TOKEN, FROM, TO, VALUE);
+ vm.stopPrank();
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Single-token rule
+ //////////////////////////////////////////////////////////////*/
+
+ function testDocumentedPreimageMatchesTheStorageKey() public view {
+ assertEq(rule.approvedCount(FROM, TO, VALUE), 1, "premise: one approval recorded");
+
+ // Formulation (1) from the NatSpec: explicit padding.
+ bytes32 padded = keccak256(abi.encodePacked(FROM, bytes12(0), TO, bytes12(0), VALUE));
+ assertEq(rule.approvalCounts(padded), 1, "documented encodePacked form must hit the key");
+
+ // Formulation (2): left-aligned words. Must be the identical preimage.
+ bytes32 words = keccak256(abi.encode(bytes32(bytes20(FROM)), bytes32(bytes20(TO)), VALUE));
+ assertEq(words, padded, "the two documented formulations must agree");
+ assertEq(rule.approvalCounts(words), 1, "documented abi.encode form must hit the key");
+ }
+
+ function testTheTwoStandardEncodingsDoNotMatch() public view {
+ // The point of the NatSpec warning: both of these look right and are wrong.
+ assertEq(
+ rule.approvalCounts(keccak256(abi.encodePacked(FROM, TO, VALUE))),
+ 0,
+ "abi.encodePacked(from,to,value) must NOT be the key"
+ );
+ assertEq(
+ rule.approvalCounts(keccak256(abi.encode(FROM, TO, VALUE))),
+ 0,
+ "abi.encode(from,to,value) must NOT be the key"
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Multi-token rule
+ //////////////////////////////////////////////////////////////*/
+
+ function testMultiTokenDocumentedPreimageMatchesTheStorageKey() public view {
+ assertEq(multi.approvedCount(TOKEN, FROM, TO, VALUE), 1, "premise: one approval recorded");
+
+ bytes32 padded = keccak256(abi.encodePacked(TOKEN, bytes12(0), FROM, bytes12(0), TO, bytes12(0), VALUE));
+ assertEq(multi.approvalCounts(padded), 1, "documented form must hit the key");
+
+ bytes32 words =
+ keccak256(abi.encode(bytes32(bytes20(TOKEN)), bytes32(bytes20(FROM)), bytes32(bytes20(TO)), VALUE));
+ assertEq(words, padded, "the two documented formulations must agree");
+ assertEq(multi.approvalCounts(words), 1, "documented form must hit the key");
+ }
+
+ function testMultiTokenIsKeyedOnTheTokenToo() public view {
+ // Same (from, to, value) under a different token must be a different key.
+ bytes32 otherToken =
+ keccak256(abi.encodePacked(address(0xDEAD), bytes12(0), FROM, bytes12(0), TO, bytes12(0), VALUE));
+ assertEq(multi.approvalCounts(otherToken), 0);
+ }
+}
diff --git a/test/RuleConditionalTransferLightMultiToken/MultiTokenSurface.t.sol b/test/RuleConditionalTransferLightMultiToken/MultiTokenSurface.t.sol
index e88690a1..c9875d18 100644
--- a/test/RuleConditionalTransferLightMultiToken/MultiTokenSurface.t.sol
+++ b/test/RuleConditionalTransferLightMultiToken/MultiTokenSurface.t.sol
@@ -16,6 +16,12 @@ contract MultiTokenSurface is Test, HelperContract {
/// constants clash with the multi-token variant's.
error RuleConditionalTransferLightMultiToken_TransferApprovalNotFound();
+ /// @dev Redeclared for the same reason as the error above: the single-token `TransferApproved`
+ /// inherited via `HelperContract` has a different signature (no `token` parameter).
+ event TransferApproved(
+ address indexed token, address indexed from, address indexed to, uint256 value, uint256 count
+ );
+
uint8 private constant CODE_NOT_APPROVED = 46;
RuleConditionalTransferLightMultiToken private rule;
@@ -36,6 +42,27 @@ contract MultiTokenSurface is Test, HelperContract {
assertFalse(rule.canReturnTransferRestrictionCode(CODE_NONEXISTENT));
}
+ /**
+ * @notice The approval count carried by {TransferApproved} must be the post-increment value.
+ * @dev `_approveTransfer` keeps the new count in a local rather than reading the slot back after
+ * storing it. Equivalent by construction, but nothing else asserts the event payload, so
+ * this pins it: a second approval of the same transfer must report 2, not 1 and not 0.
+ */
+ function test_ApproveTransferEmitsPostIncrementCount() public {
+ vm.expectEmit(true, true, true, true);
+ emit TransferApproved(ADDRESS1, ADDRESS2, ADDRESS3, 10, 1);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, ADDRESS3, 10);
+
+ vm.expectEmit(true, true, true, true);
+ emit TransferApproved(ADDRESS1, ADDRESS2, ADDRESS3, 10, 2);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, ADDRESS3, 10);
+
+ // The event and the getter must agree.
+ assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, ADDRESS3, 10), 2);
+ }
+
function test_MessageForTransferRestriction() public view {
assertEq(
rule.messageForTransferRestriction(CODE_NOT_APPROVED),
diff --git a/test/RuleERC2980/RuleERC2980.t.sol b/test/RuleERC2980/RuleERC2980.t.sol
index 317a79ca..26cbf3de 100644
--- a/test/RuleERC2980/RuleERC2980.t.sol
+++ b/test/RuleERC2980/RuleERC2980.t.sol
@@ -148,6 +148,53 @@ contract RuleERC2980Test is Test, HelperContract {
assertFalse(ruleERC2980.frozenlist(ZERO_ADDRESS));
}
+ /**
+ * @notice The zero address reverts the WHOLE batch; duplicates are still skipped.
+ * @dev The batch convention is "non-reverting" only for duplicates and missing entries.
+ * `address(0)` is rejected on every add path, batch included, because {AddWhitelistAddresses}
+ * echoes the input array -- skipping the sentinel would make the event report it as a list
+ * member. `RuleERC2980` keeps its own copy of this guard (`RuleERC2980Internal`) rather than
+ * sharing `RuleAddressSetInternal`, so it needs its own test: the whitelist-rule test does
+ * not cover this code path.
+ */
+ function testBatchAddRejectsZeroAddressAndAppliesNothing() public {
+ address[] memory withZero = new address[](3);
+ withZero[0] = ADDRESS1;
+ withZero[1] = ZERO_ADDRESS;
+ withZero[2] = ADDRESS3;
+
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectRevert(RuleERC2980InvariantStorage.RuleERC2980_ZeroAddressNotAllowed.selector);
+ ruleERC2980.addWhitelistAddresses(withZero);
+
+ vm.expectRevert(RuleERC2980InvariantStorage.RuleERC2980_ZeroAddressNotAllowed.selector);
+ ruleERC2980.addFrozenlistAddresses(withZero);
+ vm.stopPrank();
+
+ // Atomic: the valid entries either side of the sentinel are NOT applied.
+ assertFalse(ruleERC2980.whitelist(ADDRESS1), "batch must not partially apply");
+ assertFalse(ruleERC2980.whitelist(ADDRESS3), "batch must not partially apply");
+ assertFalse(ruleERC2980.frozenlist(ADDRESS1));
+ assertFalse(ruleERC2980.frozenlist(ADDRESS3));
+ assertFalse(ruleERC2980.whitelist(ZERO_ADDRESS));
+ assertFalse(ruleERC2980.frozenlist(ZERO_ADDRESS));
+ }
+
+ function testBatchAddStillSkipsDuplicates() public {
+ // The contrast that makes the convention coherent: duplicates are skipped, not rejected.
+ address[] memory withDuplicate = new address[](3);
+ withDuplicate[0] = ADDRESS1;
+ withDuplicate[1] = ADDRESS2; // already whitelisted in setUp
+ withDuplicate[2] = ADDRESS3;
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ ruleERC2980.addWhitelistAddresses(withDuplicate);
+
+ assertTrue(ruleERC2980.whitelist(ADDRESS1));
+ assertTrue(ruleERC2980.whitelist(ADDRESS2));
+ assertTrue(ruleERC2980.whitelist(ADDRESS3));
+ }
+
/*//////////////////////////////////////////////////////////////
FROZENLIST — SENDER FROZEN
//////////////////////////////////////////////////////////////*/
diff --git a/test/RuleMaxBalance/Ownable/RuleMaxBalanceOwnable2Step.t.sol b/test/RuleMaxBalance/Ownable/RuleMaxBalanceOwnable2Step.t.sol
new file mode 100644
index 00000000..87e806b1
--- /dev/null
+++ b/test/RuleMaxBalance/Ownable/RuleMaxBalanceOwnable2Step.t.sol
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
+import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol";
+import {RuleMaxBalanceOwnable2Step} from "src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol";
+import {BalanceOfMock} from "src/mocks/BalanceOfMock.sol";
+
+contract RuleMaxBalanceOwnable2StepTest is Test {
+ address constant OWNER = address(0xA11CE);
+ address constant ATTACKER = address(0xBAD);
+ address constant ALICE = address(0x11);
+ address constant CUSTODIAN = address(0x13);
+ uint256 constant CAP = 100;
+
+ BalanceOfMock private token;
+ RuleMaxBalanceOwnable2Step private rule;
+
+ function setUp() public {
+ token = new BalanceOfMock();
+ rule = new RuleMaxBalanceOwnable2Step(OWNER, address(token), CAP);
+ }
+
+ function testOwnerManagesTheCap() public {
+ vm.prank(OWNER);
+ rule.setMaxBalance(500);
+ assertEq(rule.maxBalance(), 500);
+ }
+
+ function testNonOwnerCannotManageTheCap() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ rule.setMaxBalance(500);
+ }
+
+ function testOwnerManagesExemptions() public {
+ vm.prank(OWNER);
+ rule.addExemptAddress(CUSTODIAN);
+ assertTrue(rule.isExemptAddress(CUSTODIAN));
+
+ vm.prank(OWNER);
+ rule.removeExemptAddress(CUSTODIAN);
+ assertFalse(rule.isExemptAddress(CUSTODIAN));
+ }
+
+ function testNonOwnerCannotManageExemptions() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ rule.addExemptAddress(CUSTODIAN);
+ }
+
+ function testOwnerManagesBatchExemptions() public {
+ address[] memory batch = new address[](1);
+ batch[0] = CUSTODIAN;
+
+ vm.prank(OWNER);
+ rule.addExemptAddresses(batch);
+ assertEq(rule.exemptAddressCount(), 1);
+
+ vm.prank(OWNER);
+ rule.removeExemptAddresses(batch);
+ assertEq(rule.exemptAddressCount(), 0);
+ }
+
+ function testNonOwnerCannotChangeTheToken() public {
+ BalanceOfMock other = new BalanceOfMock();
+ vm.prank(ATTACKER);
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, ATTACKER));
+ rule.setBalanceToken(address(other));
+ }
+
+ function testOwnerChangesTheToken() public {
+ BalanceOfMock other = new BalanceOfMock();
+ vm.prank(OWNER);
+ rule.setBalanceToken(address(other));
+ assertEq(address(rule.balanceToken()), address(other));
+ }
+
+ function testTheCapStillApplies() public {
+ token.setBalance(ALICE, CAP);
+ assertEq(rule.detectTransferRestriction(address(0x99), ALICE, 1), rule.CODE_MAX_BALANCE_EXCEEDED());
+ }
+
+ function testRemainingCapacity() public {
+ token.setBalance(ALICE, 40);
+ (, uint256 headroom) = rule.remainingCapacity(ALICE);
+ assertEq(headroom, 60);
+ }
+
+ function testSupportsInterface() public view {
+ // IERC165 is satisfied by the Ownable2Step module, i.e. the FIRST operand.
+ assertTrue(rule.supportsInterface(type(IERC165).interfaceId));
+ // IRule is satisfied only by RuleTransferValidation, so this exercises the second operand
+ // that the short-circuit above never reaches.
+ assertTrue(rule.supportsInterface(RuleInterfaceId.IRULE_INTERFACE_ID));
+ assertFalse(rule.supportsInterface(0xdeadbeef));
+ }
+}
diff --git a/test/RuleMaxBalance/RuleMaxBalanceCMTATIntegration.t.sol b/test/RuleMaxBalance/RuleMaxBalanceCMTATIntegration.t.sol
new file mode 100644
index 00000000..783c9da7
--- /dev/null
+++ b/test/RuleMaxBalance/RuleMaxBalanceCMTATIntegration.t.sol
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {CMTATDeployment} from "test/utils/CMTATDeployment.sol";
+import {RuleEngine} from "RuleEngine/deployment/RuleEngine.sol";
+import {RuleMaxBalance} from "src/rules/validation/deployment/RuleMaxBalance.sol";
+import {
+ RuleMaxBalanceInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol";
+import {RuleWhitelist} from "src/rules/validation/deployment/RuleWhitelist.sol";
+
+/**
+ * @notice End-to-end: the cap enforced by a real CMTAT token through a RuleEngine.
+ * @dev Two things are worth proving against a real token rather than a mock. First, the rule reads
+ * `balanceOf` from the token it is configured with, so the balance it sees is the one the token
+ * is about to change. Second, the documented mitigation actually mitigates: the last test pairs
+ * the cap with `RuleWhitelist` and shows the split-wallet bypass is only closed by the operator
+ * admitting one address per investor -- the whitelist alone does not close it.
+ */
+contract RuleMaxBalanceCMTATIntegration is Test, HelperContract, RuleMaxBalanceInvariantStorage {
+ uint256 constant CAP = 1000;
+
+ RuleEngine private ruleEngine;
+ RuleMaxBalance private rule;
+
+ address constant INVESTOR = address(0x101);
+ address constant INVESTOR_SECOND_WALLET = address(0x102);
+ address constant CUSTODIAN = address(0x103);
+ address constant OTHER = address(0x104);
+
+ function setUp() public {
+ cmtatDeployment = new CMTATDeployment();
+ cmtatContract = cmtatDeployment.cmtat();
+
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleMaxBalance(DEFAULT_ADMIN_ADDRESS, address(cmtatContract), CAP);
+ ruleEngine = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, address(cmtatContract));
+ ruleEngine.addRule(rule);
+ cmtatContract.setRuleEngine(ruleEngine);
+ vm.stopPrank();
+ }
+
+ function testMintUpToTheCapSucceeds() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+ assertEq(cmtatContract.balanceOf(INVESTOR), CAP);
+ }
+
+ /**
+ * @notice Pins the pre-update accounting the whole check depends on.
+ * @dev The rule compares `balanceOf(to) + value` against the cap, which is only correct while
+ * `balanceOf(to)` still excludes `value`. CMTAT calls `_checkTransferred(...)` before
+ * `ERC20Upgradeable._transfer(...)`, so it does. If a token ever notified compliance
+ * *after* updating balances, this mint of exactly the cap would be seen as `CAP + CAP` and
+ * rejected -- the effective cap would silently halve. This test is the alarm for that.
+ */
+ function testMintExactlyToTheCapProvesPreUpdateAccounting() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+ assertEq(cmtatContract.balanceOf(INVESTOR), CAP, "a mint of exactly the cap must succeed");
+
+ // And the boundary is exact: one more unit is rejected.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectRevert();
+ cmtatContract.mint(INVESTOR, 1);
+ }
+
+ function testMintPastTheCapIsBlocked() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ vm.expectRevert();
+ cmtatContract.mint(INVESTOR, 1);
+ }
+
+ function testTransferThatWouldBreachTheCapIsBlocked() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+ cmtatContract.mint(OTHER, CAP);
+ vm.stopPrank();
+
+ // OTHER is already at the cap, so it cannot receive anything more.
+ vm.prank(INVESTOR);
+ vm.expectRevert();
+ cmtatContract.transfer(OTHER, 1);
+ }
+
+ function testSendingDownFreesHeadroom() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+
+ vm.prank(INVESTOR);
+ cmtatContract.transfer(OTHER, 400);
+
+ // INVESTOR is now 400 under the cap and can receive again.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, 400);
+ assertEq(cmtatContract.balanceOf(INVESTOR), CAP);
+ }
+
+ function testExemptCustodianMayExceedTheCap() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addExemptAddress(CUSTODIAN);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(CUSTODIAN, CAP * 10);
+ assertEq(cmtatContract.balanceOf(CUSTODIAN), CAP * 10);
+ }
+
+ function testBurningIsNeverBlocked() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ cmtatContract.mint(INVESTOR, CAP);
+ cmtatContract.burn(INVESTOR, CAP, "");
+ vm.stopPrank();
+ assertEq(cmtatContract.balanceOf(INVESTOR), 0);
+ }
+
+ /**
+ * @notice The documented limitation, end to end: one investor, two wallets, twice the cap.
+ * @dev Adding `RuleWhitelist` does **not** by itself close this -- the whitelist admits
+ * addresses, and if the operator admits both of an investor's wallets the cap is still
+ * doubled. The mitigation is the operator policy of one admitted address per investor,
+ * which is what the rule documentation requires. This test pins the exposure so the
+ * documentation cannot quietly drift away from the behaviour.
+ */
+ function testSplitWalletsBypassTheCapEvenWithAWhitelist() public {
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ RuleWhitelist whitelist = new RuleWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, false, true);
+ // The operator admits BOTH wallets of the same investor -- the policy failure.
+ whitelist.addAddress(INVESTOR);
+ whitelist.addAddress(INVESTOR_SECOND_WALLET);
+ ruleEngine.addRule(whitelist);
+
+ cmtatContract.mint(INVESTOR, CAP);
+ cmtatContract.mint(INVESTOR_SECOND_WALLET, CAP);
+ vm.stopPrank();
+
+ // Same person, 2x the cap, no rule objected.
+ assertEq(cmtatContract.balanceOf(INVESTOR) + cmtatContract.balanceOf(INVESTOR_SECOND_WALLET), CAP * 2);
+ }
+}
diff --git a/test/RuleMaxBalance/RuleMaxBalanceUnit.t.sol b/test/RuleMaxBalance/RuleMaxBalanceUnit.t.sol
new file mode 100644
index 00000000..b56d658f
--- /dev/null
+++ b/test/RuleMaxBalance/RuleMaxBalanceUnit.t.sol
@@ -0,0 +1,386 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";
+import {RuleMaxBalance} from "src/rules/validation/deployment/RuleMaxBalance.sol";
+import {
+ RuleMaxBalanceInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleMaxBalanceInvariantStorage.sol";
+import {
+ RuleAddressSetInvariantStorage
+} from "src/rules/validation/abstract/RuleAddressSet/invariantStorage/RuleAddressSetInvariantStorage.sol";
+import {BalanceOfMock} from "src/mocks/BalanceOfMock.sol";
+
+contract RuleMaxBalanceUnit is Test, RuleMaxBalanceInvariantStorage, RuleAddressSetInvariantStorage {
+ address constant ADMIN = address(0xA11CE);
+ address constant ALICE = address(0x11);
+ address constant BOB = address(0x12);
+ address constant CUSTODIAN = address(0x13);
+ address constant ATTACKER = address(0xBAD);
+ address constant ZERO = address(0);
+
+ uint256 constant CAP = 100;
+ uint8 constant OK = 0;
+
+ BalanceOfMock private token;
+ RuleMaxBalance private rule;
+
+ function setUp() public {
+ token = new BalanceOfMock();
+ rule = new RuleMaxBalance(ADMIN, address(token), CAP);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ CONFIGURATION
+ //////////////////////////////////////////////////////////////*/
+
+ function testConstructorStoresConfiguration() public view {
+ assertEq(address(rule.balanceToken()), address(token));
+ assertEq(rule.maxBalance(), CAP);
+ }
+
+ function testConstructorRejectsZeroToken() public {
+ vm.expectRevert(RuleMaxBalance_TokenAddressZeroNotAllowed.selector);
+ new RuleMaxBalance(ADMIN, ZERO, CAP);
+ }
+
+ function testConstructorRejectsNonContractToken() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxBalance_TokenIsNotAContract.selector, ALICE));
+ new RuleMaxBalance(ADMIN, ALICE, CAP);
+ }
+
+ function testConstructorAnnouncesConfiguration() public {
+ vm.expectEmit(true, true, true, true);
+ emit MaxBalanceTokenUpdated(address(token));
+ vm.expectEmit(true, true, true, true);
+ emit MaxBalanceUpdated(CAP);
+ new RuleMaxBalance(ADMIN, address(token), CAP);
+ }
+
+ function testSetMaxBalance() public {
+ vm.prank(ADMIN);
+ rule.setMaxBalance(500);
+ assertEq(rule.maxBalance(), 500);
+ }
+
+ function testSetMaxBalanceRejectsNonManager() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert(
+ abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, ATTACKER, MAX_BALANCE_ROLE)
+ );
+ rule.setMaxBalance(500);
+ }
+
+ function testSetBalanceTokenRejectsNonContract() public {
+ vm.prank(ADMIN);
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxBalance_TokenIsNotAContract.selector, ALICE));
+ rule.setBalanceToken(ALICE);
+ }
+
+ /**
+ * @notice A token with code whose `balanceOf` reverts is rejected at configuration time.
+ * @dev Covers the `catch` in `_setBalanceToken`. Code alone is not enough: the probe must
+ * actually succeed, otherwise every transfer would later be blocked with code 83 by a
+ * misconfiguration that could have been caught at setup.
+ */
+ function testConstructorRejectsTokenWhoseBalanceOfReverts() public {
+ BalanceOfMock broken = new BalanceOfMock();
+ broken.setReverting(true);
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxBalance_TokenBalanceUnavailable.selector, address(broken)));
+ new RuleMaxBalance(ADMIN, address(broken), CAP);
+ }
+
+ function testSetBalanceTokenRejectsTokenWhoseBalanceOfReverts() public {
+ BalanceOfMock broken = new BalanceOfMock();
+ broken.setReverting(true);
+ vm.prank(ADMIN);
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxBalance_TokenBalanceUnavailable.selector, address(broken)));
+ rule.setBalanceToken(address(broken));
+ }
+
+ function testSetBalanceTokenSucceedsAndAnnounces() public {
+ BalanceOfMock other = new BalanceOfMock();
+ vm.expectEmit(true, true, true, true);
+ emit MaxBalanceTokenUpdated(address(other));
+ vm.prank(ADMIN);
+ rule.setBalanceToken(address(other));
+ assertEq(address(rule.balanceToken()), address(other));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE CAP ITSELF
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferUnderTheCapIsAllowed() public {
+ token.setBalance(BOB, 40);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 60), OK);
+ }
+
+ function testTransferExactlyToTheCapIsAllowed() public {
+ token.setBalance(BOB, 100);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 0), OK);
+ token.setBalance(BOB, 99);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 1), OK);
+ }
+
+ function testTransferOverTheCapIsRejected() public {
+ token.setBalance(BOB, 40);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 61), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ function testHolderAlreadyOverTheCapCannotReceiveMore() public {
+ token.setBalance(BOB, 500);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 1), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ /// The sender is never screened: reducing a balance cannot breach a maximum.
+ function testSenderOverTheCapMayStillSend() public {
+ token.setBalance(ALICE, 500);
+ token.setBalance(BOB, 0);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 100), OK);
+ }
+
+ /// A mint raises the receiver's balance, so it is capped like any transfer.
+ function testMintIsCapped() public {
+ token.setBalance(BOB, 100);
+ assertEq(rule.detectTransferRestriction(ZERO, BOB, 1), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ /// Burning cannot breach a maximum, and address(0) is a sentinel rather than a holder.
+ function testBurnIsExempt() public {
+ token.setBalance(ZERO, type(uint256).max);
+ assertEq(rule.detectTransferRestriction(ALICE, ZERO, type(uint256).max), OK);
+ }
+
+ /// No magic zero: a cap of 0 forbids holding, it does not disable the rule.
+ function testZeroCapForbidsHolding() public {
+ vm.prank(ADMIN);
+ rule.setMaxBalance(0);
+ token.setBalance(BOB, 0);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 1), CODE_MAX_BALANCE_EXCEEDED);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 0), OK);
+ }
+
+ /// `balance + value` must not overflow the MUST-NOT-revert view.
+ function testNoOverflowNearMaxUint() public {
+ vm.prank(ADMIN);
+ rule.setMaxBalance(type(uint256).max);
+ token.setBalance(BOB, type(uint256).max - 1);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 1), OK);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 2), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ EXEMPTIONS
+ //////////////////////////////////////////////////////////////*/
+
+ function testExemptAddressMayHoldAnyAmount() public {
+ token.setBalance(CUSTODIAN, type(uint256).max - 1);
+ assertEq(rule.detectTransferRestriction(ALICE, CUSTODIAN, 1), CODE_MAX_BALANCE_EXCEEDED);
+
+ vm.prank(ADMIN);
+ rule.addExemptAddress(CUSTODIAN);
+
+ assertTrue(rule.isExemptAddress(CUSTODIAN));
+ assertEq(rule.detectTransferRestriction(ALICE, CUSTODIAN, 1), OK);
+ }
+
+ function testAddExemptAddressEmits() public {
+ vm.expectEmit(true, true, true, true);
+ emit ExemptAddressAdded(CUSTODIAN);
+ vm.prank(ADMIN);
+ rule.addExemptAddress(CUSTODIAN);
+ assertEq(rule.exemptAddressCount(), 1);
+ }
+
+ function testRemoveExemptAddressRestoresTheCap() public {
+ vm.startPrank(ADMIN);
+ rule.addExemptAddress(CUSTODIAN);
+ vm.expectEmit(true, true, true, true);
+ emit ExemptAddressRemoved(CUSTODIAN);
+ rule.removeExemptAddress(CUSTODIAN);
+ vm.stopPrank();
+
+ assertFalse(rule.isExemptAddress(CUSTODIAN));
+ token.setBalance(CUSTODIAN, CAP);
+ assertEq(rule.detectTransferRestriction(ALICE, CUSTODIAN, 1), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ function testAddExemptAddressRejectsDuplicate() public {
+ vm.startPrank(ADMIN);
+ rule.addExemptAddress(CUSTODIAN);
+ vm.expectRevert(RuleAddressSet_AddressAlreadyListed.selector);
+ rule.addExemptAddress(CUSTODIAN);
+ vm.stopPrank();
+ }
+
+ function testRemoveExemptAddressRejectsUnknown() public {
+ vm.prank(ADMIN);
+ vm.expectRevert(RuleAddressSet_AddressNotFound.selector);
+ rule.removeExemptAddress(CUSTODIAN);
+ }
+
+ function testAddExemptAddressRejectsZeroAddress() public {
+ vm.prank(ADMIN);
+ vm.expectRevert(RuleAddressSet_ZeroAddressNotAllowed.selector);
+ rule.addExemptAddress(ZERO);
+ }
+
+ /// The batch path is guarded by the shared library's function pointer; the whole batch reverts.
+ function testBatchExemptionRejectsZeroAddressAndAppliesNothing() public {
+ address[] memory batch = new address[](2);
+ batch[0] = CUSTODIAN;
+ batch[1] = ZERO;
+
+ vm.prank(ADMIN);
+ vm.expectRevert(RuleAddressSet_ZeroAddressNotAllowed.selector);
+ rule.addExemptAddresses(batch);
+
+ assertEq(rule.exemptAddressCount(), 0, "a rejected batch must apply nothing");
+ assertFalse(rule.isExemptAddress(CUSTODIAN));
+ }
+
+ function testExemptionRejectsNonManager() public {
+ vm.prank(ATTACKER);
+ vm.expectRevert(
+ abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, ATTACKER, MAX_BALANCE_ROLE)
+ );
+ rule.addExemptAddress(CUSTODIAN);
+ }
+
+ function testBatchExemptionReportsCounters() public {
+ address[] memory batch = new address[](2);
+ batch[0] = CUSTODIAN;
+ batch[1] = BOB;
+
+ vm.startPrank(ADMIN);
+ rule.addExemptAddresses(batch);
+ assertEq(rule.exemptAddressCount(), 2);
+
+ // A second identical batch is entirely redundant: 0 added, 2 skipped.
+ vm.expectEmit(true, true, true, true);
+ emit ExemptAddressesAdded(batch, 0, 2);
+ rule.addExemptAddresses(batch);
+
+ vm.expectEmit(true, true, true, true);
+ emit ExemptAddressesRemoved(batch, 2, 0);
+ rule.removeExemptAddresses(batch);
+ vm.stopPrank();
+
+ assertEq(rule.exemptAddressCount(), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ REVERT-FREE READ PATH
+ //////////////////////////////////////////////////////////////*/
+
+ /// A token that breaks after configuration must yield a code, never revert the view.
+ function testBrokenTokenYieldsCodeInsteadOfReverting() public {
+ token.setReverting(true);
+ assertEq(rule.detectTransferRestriction(ALICE, BOB, 1), CODE_BALANCE_UNAVAILABLE);
+ assertFalse(rule.canTransfer(ALICE, BOB, 1));
+ }
+
+ function testBrokenTokenStillAllowsBurnAndExempt() public {
+ vm.prank(ADMIN);
+ rule.addExemptAddress(CUSTODIAN);
+ token.setReverting(true);
+ // Neither branch reads a balance, so neither is affected.
+ assertEq(rule.detectTransferRestriction(ALICE, ZERO, 1), OK);
+ assertEq(rule.detectTransferRestriction(ALICE, CUSTODIAN, 1), OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ WRITE PATH AND VIEWS
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferredRevertsWhenOverTheCap() public {
+ token.setBalance(BOB, CAP);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleMaxBalance_InvalidTransfer.selector, address(rule), ALICE, BOB, 1, CODE_MAX_BALANCE_EXCEEDED
+ )
+ );
+ rule.transferred(ALICE, BOB, 1);
+ }
+
+ function testTransferredFromRevertsWhenOverTheCap() public {
+ token.setBalance(BOB, CAP);
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleMaxBalance_InvalidTransferFrom.selector,
+ address(rule),
+ ATTACKER,
+ ALICE,
+ BOB,
+ 1,
+ CODE_MAX_BALANCE_EXCEEDED
+ )
+ );
+ rule.transferred(ATTACKER, ALICE, BOB, 1);
+ }
+
+ /// The spender is irrelevant: the cap constrains who ends up holding the tokens.
+ function testSpenderDoesNotAffectTheOutcome() public {
+ token.setBalance(BOB, 40);
+ assertEq(rule.detectTransferRestrictionFrom(ATTACKER, ALICE, BOB, 60), OK);
+ assertEq(rule.detectTransferRestrictionFrom(CUSTODIAN, ALICE, BOB, 61), CODE_MAX_BALANCE_EXCEEDED);
+ }
+
+ function testRemainingCapacity() public {
+ token.setBalance(BOB, 40);
+ (uint8 code, uint256 headroom) = rule.remainingCapacity(BOB);
+ assertEq(code, OK);
+ assertEq(headroom, 60);
+
+ token.setBalance(BOB, 500);
+ (, headroom) = rule.remainingCapacity(BOB);
+ assertEq(headroom, 0);
+
+ vm.prank(ADMIN);
+ rule.addExemptAddress(BOB);
+ (, headroom) = rule.remainingCapacity(BOB);
+ assertEq(headroom, type(uint256).max);
+ }
+
+ function testRemainingCapacityReportsBrokenToken() public {
+ token.setReverting(true);
+ (uint8 code,) = rule.remainingCapacity(BOB);
+ assertEq(code, CODE_BALANCE_UNAVAILABLE);
+ }
+
+ function testMessagesAndCodes() public view {
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_MAX_BALANCE_EXCEEDED));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_BALANCE_UNAVAILABLE));
+ assertFalse(rule.canReturnTransferRestrictionCode(1));
+ assertEq(rule.messageForTransferRestriction(CODE_MAX_BALANCE_EXCEEDED), TEXT_MAX_BALANCE_EXCEEDED);
+ assertEq(rule.messageForTransferRestriction(CODE_BALANCE_UNAVAILABLE), TEXT_BALANCE_UNAVAILABLE);
+ assertEq(rule.messageForTransferRestriction(1), TEXT_CODE_NOT_FOUND);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE DOCUMENTED BYPASS
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice Pins the limitation the documentation warns about: the cap is per **address**, so one
+ * holder with two addresses can hold 2x the cap without the rule objecting.
+ * @dev This asserts behaviour that is correct-as-designed but exploitable in isolation. It is
+ * why `doc/technical/contracts/RuleMaxBalance.md` requires pairing the rule with a one-address-per-
+ * investor rule. If a future change makes this fail, the mitigation is no longer needed and
+ * the documentation must be updated with it.
+ */
+ function testCapIsPerAddressSoSplittingBypassesIt() public {
+ address walletA = address(0x21);
+ address walletB = address(0x22);
+
+ token.setBalance(walletA, CAP);
+ token.setBalance(walletB, 0);
+
+ // walletA is full...
+ assertEq(rule.detectTransferRestriction(ALICE, walletA, 1), CODE_MAX_BALANCE_EXCEEDED);
+ // ...but the same person's second wallet accepts another full cap.
+ assertEq(rule.detectTransferRestriction(ALICE, walletB, CAP), OK);
+ }
+}
diff --git a/test/RuleMaxTotalSupply/RuleMaxTotalSupplyAccessControlRoleMembers.t.sol b/test/RuleMaxTotalSupply/RuleMaxTotalSupplyAccessControlRoleMembers.t.sol
index 0be18917..e051041b 100644
--- a/test/RuleMaxTotalSupply/RuleMaxTotalSupplyAccessControlRoleMembers.t.sol
+++ b/test/RuleMaxTotalSupply/RuleMaxTotalSupplyAccessControlRoleMembers.t.sol
@@ -6,11 +6,12 @@ import {
IAccessControlEnumerableLike
} from "../utils/AccessControlEnumerableTestBase.sol";
import {RuleMaxTotalSupply} from "src/rules/validation/deployment/RuleMaxTotalSupply.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
contract RuleMaxTotalSupplyAccessControlRoleMembers is AccessControlEnumerableTestBase {
function _deployAccessControl() internal override returns (IAccessControlEnumerableLike, address) {
address adminAddr = DEFAULT_ADMIN_ADDRESS;
- RuleMaxTotalSupply rule = new RuleMaxTotalSupply(adminAddr, ADDRESS1, 1000);
+ RuleMaxTotalSupply rule = new RuleMaxTotalSupply(adminAddr, address(new TotalSupplyMock()), 1000);
return (IAccessControlEnumerableLike(address(rule)), adminAddr);
}
diff --git a/test/RuleMaxTotalSupply/RuleMaxTotalSupplyUnit.t.sol b/test/RuleMaxTotalSupply/RuleMaxTotalSupplyUnit.t.sol
index a27d2d3d..b091b6d7 100644
--- a/test/RuleMaxTotalSupply/RuleMaxTotalSupplyUnit.t.sol
+++ b/test/RuleMaxTotalSupply/RuleMaxTotalSupplyUnit.t.sol
@@ -21,6 +21,88 @@ contract RuleMaxTotalSupplyUnit is Test, HelperContract {
new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, 100);
}
+ /*//////////////////////////////////////////////////////////////
+ TOKEN CONTRACT VALIDITY (F-2 REGRESSION)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice A non-contract token is rejected by an explicit check, not by the compiler's
+ * uncatchable extcodesize revert that the `totalSupply()` probe would produce.
+ */
+ function testConstructor_RevertsOnNonContractToken() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxTotalSupply_TokenIsNotAContract.selector, ADDRESS1));
+ new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, ADDRESS1, 100);
+ }
+
+ function testSetTokenContract_RevertsOnNonContract() public {
+ vm.expectRevert(abi.encodeWithSelector(RuleMaxTotalSupply_TokenIsNotAContract.selector, ADDRESS1));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenContract(ADDRESS1);
+ }
+
+ /**
+ * @notice `totalSupply()` is mandatory: a contract without it is rejected at configuration
+ * rather than silently bricking the read path later.
+ */
+ function testConstructor_RevertsWhenTotalSupplyMissing() public {
+ NoTotalSupplyMock noSupply = new NoTotalSupplyMock();
+ vm.expectRevert(
+ abi.encodeWithSelector(RuleMaxTotalSupply_TokenTotalSupplyUnavailable.selector, address(noSupply))
+ );
+ new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, address(noSupply), 100);
+ }
+
+ function testSetTokenContract_RevertsWhenTotalSupplyMissing() public {
+ NoTotalSupplyMock noSupply = new NoTotalSupplyMock();
+ vm.expectRevert(
+ abi.encodeWithSelector(RuleMaxTotalSupply_TokenTotalSupplyUnavailable.selector, address(noSupply))
+ );
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenContract(address(noSupply));
+ }
+
+ /**
+ * @notice If the token breaks AFTER configuration the read path must still return a code:
+ * the ERC-1404 / ERC-3643 views MUST NOT revert.
+ */
+ function testRevertingTotalSupplyYieldsACodeNotARevert() public {
+ RevertingTotalSupplyMock breakable = new RevertingTotalSupplyMock();
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenContract(address(breakable));
+ breakable.setRevertOnTotalSupply(true);
+
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 1), CODE_SUPPLY_ORACLE_UNAVAILABLE, "must not revert"
+ );
+ assertFalse(rule.canTransfer(ZERO_ADDRESS, ADDRESS1, 1));
+ assertEq(
+ rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 1), CODE_SUPPLY_ORACLE_UNAVAILABLE
+ );
+
+ // Transfers and burns never read the supply, so they are unaffected.
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 1), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 1), TRANSFER_OK);
+ }
+
+ function testTransferred_RevertsWithTheSupplyOracleCode() public {
+ RevertingTotalSupplyMock breakable = new RevertingTotalSupplyMock();
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setTokenContract(address(breakable));
+ breakable.setRevertOnTotalSupply(true);
+
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleMaxTotalSupply_InvalidTransfer.selector,
+ address(rule),
+ ZERO_ADDRESS,
+ ADDRESS1,
+ 1,
+ CODE_SUPPLY_ORACLE_UNAVAILABLE
+ )
+ );
+ rule.transferred(ZERO_ADDRESS, ADDRESS1, 1);
+ }
+
function testSetTokenContract_RevertsOnZero() public {
vm.expectRevert(RuleMaxTotalSupply_TokenAddressZeroNotAllowed.selector);
vm.prank(DEFAULT_ADMIN_ADDRESS);
@@ -100,11 +182,40 @@ contract RuleMaxTotalSupplyUnit is Test, HelperContract {
function testCanReturnTransferRestrictionCode() public view {
assertTrue(rule.canReturnTransferRestrictionCode(CODE_MAX_TOTAL_SUPPLY_EXCEEDED));
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_SUPPLY_ORACLE_UNAVAILABLE));
assertFalse(rule.canReturnTransferRestrictionCode(CODE_NONEXISTENT));
}
function testMessageForTransferRestriction() public view {
assertEq(rule.messageForTransferRestriction(CODE_MAX_TOTAL_SUPPLY_EXCEEDED), TEXT_MAX_TOTAL_SUPPLY_EXCEEDED);
+ assertEq(rule.messageForTransferRestriction(CODE_SUPPLY_ORACLE_UNAVAILABLE), TEXT_SUPPLY_ORACLE_UNAVAILABLE);
assertEq(rule.messageForTransferRestriction(CODE_NONEXISTENT), TEXT_CODE_NOT_FOUND);
}
}
+
+/**
+ * @notice Has code but no `totalSupply()`: the shape that previously passed configuration and then
+ * reverted the read path.
+ */
+contract NoTotalSupplyMock {
+ // Intentionally empty: it has code, but no `totalSupply()` to call.
+
+ }
+
+/**
+ * @notice A token whose `totalSupply()` can be made to revert after configuration.
+ */
+contract RevertingTotalSupplyMock {
+ bool private _shouldRevert;
+
+ function setRevertOnTotalSupply(bool shouldRevert) external {
+ _shouldRevert = shouldRevert;
+ }
+
+ function totalSupply() external view returns (uint256) {
+ require(!_shouldRevert, RevertingTotalSupplyMock_Unavailable());
+ return 0;
+ }
+
+ error RevertingTotalSupplyMock_Unavailable();
+}
diff --git a/test/RuleReceiverWhitelist/Ownable/RuleReceiverWhitelistOwnable2Step.t.sol b/test/RuleReceiverWhitelist/Ownable/RuleReceiverWhitelistOwnable2Step.t.sol
new file mode 100644
index 00000000..6dfce4a3
--- /dev/null
+++ b/test/RuleReceiverWhitelist/Ownable/RuleReceiverWhitelistOwnable2Step.t.sol
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../../HelperContract.sol";
+import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
+import {RuleReceiverWhitelistOwnable2Step} from "src/rules/validation/deployment/RuleReceiverWhitelistOwnable2Step.sol";
+import {RuleReceiverWhitelistOwnable2StepHarness} from "src/mocks/harness/RuleReceiverWhitelistHarnesses.sol";
+
+contract RuleReceiverWhitelistOwnable2StepTest is Test, HelperContract {
+ RuleReceiverWhitelistOwnable2StepHarness private rule;
+
+ function setUp() public {
+ rule = new RuleReceiverWhitelistOwnable2StepHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS);
+ }
+
+ function testOnlyOwnerCanManageList() public {
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.addAddress(ADDRESS3);
+
+ vm.expectRevert();
+ vm.prank(ADDRESS1);
+ rule.removeAddress(ADDRESS3);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS3);
+ assertTrue(rule.isAddressListed(ADDRESS3));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.removeAddress(ADDRESS3);
+ assertFalse(rule.isAddressListed(ADDRESS3));
+ }
+
+ function testOnlyOwnerCanTransferOwnership() public {
+ vm.prank(ADDRESS1);
+ vm.expectRevert();
+ rule.transferOwnership(ADDRESS2);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.transferOwnership(ADDRESS2);
+ assertEq(rule.pendingOwner(), ADDRESS2);
+ }
+
+ function testMetaTxOverridesAreReachable() public view {
+ assertEq(rule.exposedMsgSender(), address(this));
+ assertEq(rule.exposedContextSuffixLength(), 20);
+ assertGe(rule.exposedMsgData().length, 4);
+ }
+
+ function testCannotDeployWithZeroOwner() public {
+ vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, ZERO_ADDRESS));
+ new RuleReceiverWhitelistOwnable2Step(ZERO_ADDRESS, ZERO_ADDRESS);
+ }
+}
diff --git a/test/RuleReceiverWhitelist/RuleReceiverWhitelistUnit.t.sol b/test/RuleReceiverWhitelist/RuleReceiverWhitelistUnit.t.sol
new file mode 100644
index 00000000..193ce756
--- /dev/null
+++ b/test/RuleReceiverWhitelist/RuleReceiverWhitelistUnit.t.sol
@@ -0,0 +1,249 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";
+import {ERC1404ExtendInterfaceId} from "CMTAT/library/ERC1404ExtendInterfaceId.sol";
+import {RuleEngineInterfaceId} from "CMTAT/library/RuleEngineInterfaceId.sol";
+import {RuleInterfaceId} from "RuleEngine/modules/library/RuleInterfaceId.sol";
+import {AddressListInterfaceId} from "src/rules/interfaces/library/AddressListInterfaceId.sol";
+import {RuleReceiverWhitelistHarness} from "src/mocks/harness/RuleReceiverWhitelistHarnesses.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {RuleReceiverWhitelist} from "src/rules/validation/deployment/RuleReceiverWhitelist.sol";
+import {
+ RuleReceiverWhitelistInvariantStorage
+} from "src/rules/validation/abstract/invariant/RuleReceiverWhitelistInvariantStorage.sol";
+
+/**
+ * @title Unit tests for RuleReceiverWhitelist
+ * @notice The rule reproduces ERC-3643's eligibility semantics: only the receiver is screened.
+ * These tests pin each half of that — what IS checked, and just as importantly what is
+ * deliberately NOT.
+ */
+contract RuleReceiverWhitelistUnit is Test, HelperContract, RuleReceiverWhitelistInvariantStorage {
+ RuleReceiverWhitelist private rule;
+ RuleReceiverWhitelistHarness private harness;
+
+ function setUp() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule = new RuleReceiverWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS);
+ harness = new RuleReceiverWhitelistHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS1);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE RECEIVER IS SCREENED
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransfer_ToWhitelistedReceiverIsAllowed() public view {
+ assertEq(rule.detectTransferRestriction(ADDRESS2, ADDRESS1, 10), TRANSFER_OK);
+ assertTrue(rule.canTransfer(ADDRESS2, ADDRESS1, 10));
+ }
+
+ function testTransfer_ToUnlistedReceiverIsRejected() public view {
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_RECEIVER_NOT_WHITELISTED);
+ assertFalse(rule.canTransfer(ADDRESS1, ADDRESS2, 10));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE SENDER IS NOT SCREENED
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice The defining behaviour: an unlisted sender may still send to a listed receiver. This
+ * is what lets a de-listed investor exit their position instead of being trapped.
+ */
+ function testTransfer_UnlistedSenderCanStillSendToAListedReceiver() public view {
+ assertFalse(rule.isAddressListed(ADDRESS2), "sender is not listed");
+ assertEq(rule.detectTransferRestriction(ADDRESS2, ADDRESS1, 10), TRANSFER_OK);
+ }
+
+ /**
+ * @notice Removing a holder from the list must not strand their balance.
+ */
+ function testTransfer_DeListedHolderCanStillExit() public {
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS2);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.removeAddress(ADDRESS1);
+ assertFalse(rule.isAddressListed(ADDRESS1));
+
+ // ADDRESS1 is de-listed but can still send to the still-listed ADDRESS2.
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ THE SPENDER IS NOT SCREENED
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice ERC-3643: `transferFrom` "works the same way" — the spender is never checked.
+ */
+ function testTransferFrom_SpenderIsIgnored() public view {
+ assertFalse(rule.isAddressListed(ADDRESS3), "spender is not listed");
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS2, ADDRESS1, 10), TRANSFER_OK);
+ assertTrue(rule.canTransferFrom(ADDRESS3, ADDRESS2, ADDRESS1, 10));
+
+ // ...and it still rejects on the receiver, spender notwithstanding.
+ assertEq(
+ rule.detectTransferRestrictionFrom(ADDRESS1, ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_RECEIVER_NOT_WHITELISTED
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ MINT / BURN
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice ERC-3643: `mint` "only require[s] the receiver", so a mint is screened exactly like
+ * any other transfer — no `allowMint` flag.
+ */
+ function testMint_ScreensTheReceiverLikeAnyTransfer() public view {
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS1, 10), TRANSFER_OK, "listed receiver");
+ assertEq(
+ rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 10),
+ CODE_ADDRESS_RECEIVER_NOT_WHITELISTED,
+ "unlisted receiver"
+ );
+ // Same on the spender-aware mint path, where the minter arrives as `spender`.
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ZERO_ADDRESS, ADDRESS1, 10), TRANSFER_OK);
+ }
+
+ /**
+ * @notice ERC-3643: `burn` "bypasses all checks on eligibility". The exemption must be explicit,
+ * because `address(0)` can never be listed and would otherwise always be rejected.
+ */
+ function testBurn_IsAlwaysAllowed() public {
+ assertFalse(rule.isAddressListed(ZERO_ADDRESS), "zero address is never listed");
+
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 10), TRANSFER_OK, "listed sender");
+ assertEq(rule.detectTransferRestriction(ADDRESS2, ZERO_ADDRESS, 10), TRANSFER_OK, "unlisted sender");
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS2, ZERO_ADDRESS, 10), TRANSFER_OK);
+ rule.transferred(ADDRESS2, ZERO_ADDRESS, 10);
+ }
+
+ /**
+ * @notice The zero address can never enter the list, so `isAddressListed(address(0))` stays
+ * false and burn permission is never expressible as list membership.
+ */
+ function testZeroAddressCannotBeListed() public {
+ vm.expectRevert(RuleAddressSet_ZeroAddressNotAllowed.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ZERO_ADDRESS);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ WRITE PATH
+ //////////////////////////////////////////////////////////////*/
+
+ function testTransferred_RevertsOnUnlistedReceiver() public {
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleReceiverWhitelist_InvalidTransfer.selector,
+ address(rule),
+ ADDRESS1,
+ ADDRESS2,
+ 10,
+ CODE_ADDRESS_RECEIVER_NOT_WHITELISTED
+ )
+ );
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+ }
+
+ function testTransferredFrom_RevertsOnUnlistedReceiver() public {
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ RuleReceiverWhitelist_InvalidTransferFrom.selector,
+ address(rule),
+ ADDRESS3,
+ ADDRESS1,
+ ADDRESS2,
+ 10,
+ CODE_ADDRESS_RECEIVER_NOT_WHITELISTED
+ )
+ );
+ rule.transferred(ADDRESS3, ADDRESS1, ADDRESS2, 10);
+ }
+
+ function testTransferred_DoesNotRevertWhenAllowed() public {
+ rule.transferred(ADDRESS2, ADDRESS1, 10);
+ rule.transferred(ADDRESS3, ADDRESS2, ADDRESS1, 10);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ERC-1404 SURFACE
+ //////////////////////////////////////////////////////////////*/
+
+ function testCanReturnTransferRestrictionCode() public view {
+ assertTrue(rule.canReturnTransferRestrictionCode(CODE_ADDRESS_RECEIVER_NOT_WHITELISTED));
+ assertFalse(rule.canReturnTransferRestrictionCode(CODE_NONEXISTENT));
+ }
+
+ function testMessageForTransferRestriction() public view {
+ assertEq(
+ rule.messageForTransferRestriction(CODE_ADDRESS_RECEIVER_NOT_WHITELISTED),
+ TEXT_ADDRESS_RECEIVER_NOT_WHITELISTED
+ );
+ assertEq(rule.messageForTransferRestriction(CODE_NONEXISTENT), TEXT_CODE_NOT_FOUND);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ACCESS CONTROL
+ //////////////////////////////////////////////////////////////*/
+
+ function testAddAddress_OnlyAddRole() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ rule.addAddress(ADDRESS2);
+ }
+
+ function testRemoveAddress_OnlyRemoveRole() public {
+ vm.expectRevert();
+ vm.prank(ATTACKER);
+ rule.removeAddress(ADDRESS1);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ ERC-165 AND BATCH OPS
+ //////////////////////////////////////////////////////////////*/
+
+ function testSupportsInterface() public view {
+ assertTrue(rule.supportsInterface(type(IAccessControl).interfaceId));
+ assertTrue(rule.supportsInterface(RuleInterfaceId.IRULE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(ERC1404ExtendInterfaceId.ERC1404EXTEND_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(RuleEngineInterfaceId.RULE_ENGINE_INTERFACE_ID));
+ assertTrue(rule.supportsInterface(AddressListInterfaceId.IADDRESS_LIST_INTERFACE_ID), "advertises IAddressList");
+ assertFalse(rule.supportsInterface(bytes4(0xdeadbeef)));
+ }
+
+ /**
+ * @notice Batch operations come from `RuleAddressSet`: they skip duplicates instead of
+ * reverting, unlike the single-address variants.
+ */
+ function testBatchAddAndRemove() public {
+ address[] memory batch = new address[](2);
+ batch[0] = ADDRESS2;
+ batch[1] = ADDRESS3;
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddresses(batch);
+ bool[] memory listed = rule.areAddressesListed(batch);
+ assertTrue(listed[0] && listed[1], "both listed");
+ assertEq(rule.listedAddressCount(), 3);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.removeAddresses(batch);
+ listed = rule.areAddressesListed(batch);
+ assertFalse(listed[0] || listed[1], "neither listed");
+ assertEq(rule.listedAddressCount(), 1);
+ }
+
+ function testMetaTxOverridesAreReachable() public view {
+ assertEq(harness.exposedMsgSender(), address(this));
+ assertEq(harness.exposedContextSuffixLength(), 20);
+ assertGe(harness.exposedMsgData().length, 4);
+ }
+}
diff --git a/test/RuleSanctionList/Ownable/RuleSanctionsListOwnable2Step.t.sol b/test/RuleSanctionsList/Ownable/RuleSanctionsListOwnable2Step.t.sol
similarity index 100%
rename from test/RuleSanctionList/Ownable/RuleSanctionsListOwnable2Step.t.sol
rename to test/RuleSanctionsList/Ownable/RuleSanctionsListOwnable2Step.t.sol
diff --git a/test/RuleSanctionList/RuleEngineIntegration.t.sol b/test/RuleSanctionsList/RuleEngineIntegration.t.sol
similarity index 100%
rename from test/RuleSanctionList/RuleEngineIntegration.t.sol
rename to test/RuleSanctionsList/RuleEngineIntegration.t.sol
diff --git a/test/RuleSanctionList/RuleSanctionsListAccessControlRoleMembers.t.sol b/test/RuleSanctionsList/RuleSanctionsListAccessControlRoleMembers.t.sol
similarity index 100%
rename from test/RuleSanctionList/RuleSanctionsListAccessControlRoleMembers.t.sol
rename to test/RuleSanctionsList/RuleSanctionsListAccessControlRoleMembers.t.sol
diff --git a/test/RuleSanctionList/RuleSanctionListAddTest.t.sol b/test/RuleSanctionsList/RuleSanctionsListAddTest.t.sol
similarity index 100%
rename from test/RuleSanctionList/RuleSanctionListAddTest.t.sol
rename to test/RuleSanctionsList/RuleSanctionsListAddTest.t.sol
diff --git a/test/RuleSanctionsList/RuleSanctionsListDelegation.t.sol b/test/RuleSanctionsList/RuleSanctionsListDelegation.t.sol
new file mode 100644
index 00000000..2ac4439f
--- /dev/null
+++ b/test/RuleSanctionsList/RuleSanctionsListDelegation.t.sol
@@ -0,0 +1,100 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {SanctionsListExtraCheckHarness} from "src/mocks/harness/SanctionsListDelegationHarness.sol";
+import {SanctionListOracle} from "src/mocks/SanctionListOracle.sol";
+import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
+
+/**
+ * @title RuleSanctionsListDelegation
+ * @notice The `transferFrom` path must always consult the direct restriction check, whether or not
+ * an oracle is configured (`CLAUDE_ANALYSIS.md` F-2).
+ * @dev The subclass under test adds an oracle-independent check. With no oracle configured, the
+ * previous implementation returned `TRANSFER_OK` from `detectTransferRestrictionFrom` without
+ * ever reaching `_detectTransferRestriction`, so the subclass's check applied to `transfer` but
+ * not to `transferFrom`. `testExtraCheckAppliesToTransferFromWithNoOracle` fails against that
+ * implementation and is the reason the restructure exists.
+ */
+contract RuleSanctionsListDelegation is Test, HelperContract {
+ address private constant BLOCKED = address(0xB10C);
+ address private constant SANCTIONED = address(99);
+
+ SanctionListOracle private oracle;
+
+ function setUp() public {
+ oracle = new SanctionListOracle();
+ oracle.addToSanctionsList(SANCTIONED);
+ }
+
+ function _withoutOracle() internal returns (SanctionsListExtraCheckHarness) {
+ return new SanctionsListExtraCheckHarness(
+ SANCTIONLIST_OPERATOR_ADDRESS, ZERO_ADDRESS, ISanctionsList(address(0)), BLOCKED
+ );
+ }
+
+ function _withOracle() internal returns (SanctionsListExtraCheckHarness) {
+ return new SanctionsListExtraCheckHarness(
+ SANCTIONLIST_OPERATOR_ADDRESS, ZERO_ADDRESS, ISanctionsList(address(oracle)), BLOCKED
+ );
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ No oracle configured
+ //////////////////////////////////////////////////////////////*/
+
+ function testExtraCheckAppliesToTransferWithNoOracle() public {
+ // This direction always worked: the direct path calls the hook unconditionally.
+ SanctionsListExtraCheckHarness rule = _withoutOracle();
+ assertEq(rule.detectTransferRestriction(BLOCKED, ADDRESS2, 10), rule.CODE_EXTRA_BLOCKED());
+ }
+
+ function testExtraCheckAppliesToTransferFromWithNoOracle() public {
+ // THE REGRESSION: with the delegation nested inside the oracle-set branch this returned
+ // TRANSFER_OK, so `transfer` and `transferFrom` disagreed about the same pair of addresses.
+ SanctionsListExtraCheckHarness rule = _withoutOracle();
+ assertEq(
+ rule.detectTransferRestrictionFrom(ADDRESS3, BLOCKED, ADDRESS2, 10),
+ rule.CODE_EXTRA_BLOCKED(),
+ "transferFrom must reach the same hook as transfer"
+ );
+ assertFalse(rule.canTransferFrom(ADDRESS3, BLOCKED, ADDRESS2, 10));
+ }
+
+ function testTheTwoEntrypointsAgreeWithNoOracle() public {
+ SanctionsListExtraCheckHarness rule = _withoutOracle();
+ assertEq(
+ rule.detectTransferRestriction(ADDRESS1, BLOCKED, 10),
+ rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, BLOCKED, 10),
+ "the receiver leg must be screened identically on both paths"
+ );
+ // An unrelated pair is still unrestricted; the rule is not simply rejecting everything.
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ Oracle configured
+ //////////////////////////////////////////////////////////////*/
+
+ function testExtraCheckStillAppliesWithAnOracle() public {
+ SanctionsListExtraCheckHarness rule = _withOracle();
+ assertEq(rule.detectTransferRestriction(BLOCKED, ADDRESS2, 10), rule.CODE_EXTRA_BLOCKED());
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, BLOCKED, ADDRESS2, 10), rule.CODE_EXTRA_BLOCKED());
+ }
+
+ function testTheSpenderCheckStillTakesPriority() public {
+ // The oracle-driven spender check must still short-circuit ahead of the delegated hook.
+ SanctionsListExtraCheckHarness rule = _withOracle();
+ assertEq(
+ rule.detectTransferRestrictionFrom(SANCTIONED, BLOCKED, ADDRESS2, 10), CODE_ADDRESS_SPENDER_IS_SANCTIONED
+ );
+ }
+
+ function testBaseScreeningIsUnchanged() public {
+ SanctionsListExtraCheckHarness rule = _withOracle();
+ assertEq(rule.detectTransferRestriction(SANCTIONED, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_SANCTIONED);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, SANCTIONED, 10), CODE_ADDRESS_TO_IS_SANCTIONED);
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ }
+}
diff --git a/test/RuleSanctionList/RuleSanctionListDeploymentTest.t.sol b/test/RuleSanctionsList/RuleSanctionsListDeploymentTest.t.sol
similarity index 98%
rename from test/RuleSanctionList/RuleSanctionListDeploymentTest.t.sol
rename to test/RuleSanctionsList/RuleSanctionsListDeploymentTest.t.sol
index 133d6537..a7b9d44b 100644
--- a/test/RuleSanctionList/RuleSanctionListDeploymentTest.t.sol
+++ b/test/RuleSanctionsList/RuleSanctionsListDeploymentTest.t.sol
@@ -11,7 +11,7 @@ import {AccessControlModuleStandalone} from "../../src/modules/AccessControlModu
* @title General functions of the ruleSanctionList
*/
-contract RuleSanctionListDeploymentTest is Test, HelperContract {
+contract RuleSanctionsListDeploymentTest is Test, HelperContract {
RuleSanctionsList ruleSanctionList;
SanctionListOracle sanctionlistOracle;
diff --git a/test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol b/test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol
new file mode 100644
index 00000000..ba6211a8
--- /dev/null
+++ b/test/RuleSanctionsList/RuleSanctionsListMintBurnSentinel.t.sol
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {SanctionListOracle} from "src/mocks/SanctionListOracle.sol";
+import {RuleSanctionsList, ISanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
+
+/**
+ * @title RuleSanctionsListMintBurnSentinel
+ * @notice The zero address is the ERC-20 mint/burn sentinel and must never be sent to the oracle
+ * (`CLAUDE_ANALYSIS.md` F-1).
+ * @dev The oracle here sanctions `address(0)` itself -- a degenerate input a real oracle has never
+ * been asked about, and one it is free to answer either way. Before the fix the rule forwarded
+ * the sentinel to the oracle, so a `true` answer blocked EVERY mint and EVERY burn on every
+ * token using this rule, trapping holders behind a third party's handling of a non-wallet.
+ * These assertions fail without the `from != address(0)` / `to != address(0)` guards.
+ */
+contract RuleSanctionsListMintBurnSentinel is Test, HelperContract {
+ SanctionListOracle private oracle;
+ RuleSanctionsList private rule;
+
+ function setUp() public {
+ oracle = new SanctionListOracle();
+ // A real sanctioned wallet, and the sentinel.
+ oracle.addToSanctionsList(ATTACKER);
+ oracle.addToSanctionsList(ZERO_ADDRESS);
+ rule = new RuleSanctionsList(SANCTIONLIST_OPERATOR_ADDRESS, ZERO_ADDRESS, ISanctionsList(address(oracle)));
+ }
+
+ function testOracleReallyDoesSanctionTheSentinel() public view {
+ // Guards the premise of every assertion below.
+ assertTrue(oracle.isSanctioned(ZERO_ADDRESS));
+ }
+
+ function testMintIsNotBlockedWhenTheOracleSanctionsTheZeroAddress() public view {
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 10), TRANSFER_OK);
+ assertTrue(rule.canTransfer(ZERO_ADDRESS, ADDRESS2, 10));
+ }
+
+ function testBurnIsNotBlockedWhenTheOracleSanctionsTheZeroAddress() public view {
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ZERO_ADDRESS, 10), TRANSFER_OK);
+ assertTrue(rule.canTransfer(ADDRESS1, ZERO_ADDRESS, 10));
+ }
+
+ function testMintAndBurnDoNotRevertOnTheWritePath() public view {
+ // `transferred` reverts on a non-zero code, so this is the enforcement-side equivalent.
+ rule.transferred(ZERO_ADDRESS, ADDRESS2, 10);
+ rule.transferred(ADDRESS1, ZERO_ADDRESS, 10);
+ }
+
+ function testMintToASanctionedRecipientIsStillBlocked() public view {
+ // The sentinel guard must not weaken screening of the REAL participant.
+ assertEq(rule.detectTransferRestriction(ZERO_ADDRESS, ATTACKER, 10), CODE_ADDRESS_TO_IS_SANCTIONED);
+ }
+
+ function testBurnFromASanctionedHolderIsStillBlocked() public view {
+ assertEq(rule.detectTransferRestriction(ATTACKER, ZERO_ADDRESS, 10), CODE_ADDRESS_FROM_IS_SANCTIONED);
+ }
+
+ function testOrdinaryTransfersAreUnaffected() public view {
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ATTACKER, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_SANCTIONED);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ATTACKER, 10), CODE_ADDRESS_TO_IS_SANCTIONED);
+ }
+
+ /**
+ * @notice The spender leg is deliberately NOT guarded; the minter must still be screened.
+ * @dev `CLAUDE.md` records that the deny-lists screen the minter, which arrives as `spender` on
+ * the 4-arg mint path. Guarding `from`/`to` must not silently disable that.
+ */
+ function testTheMinterIsStillScreenedAsSpender() public view {
+ assertEq(
+ rule.detectTransferRestrictionFrom(ATTACKER, ZERO_ADDRESS, ADDRESS2, 10), CODE_ADDRESS_SPENDER_IS_SANCTIONED
+ );
+ }
+}
diff --git a/test/RuleSanctionList/RuleSanctionListTest.t.sol b/test/RuleSanctionsList/RuleSanctionsListTest.t.sol
similarity index 100%
rename from test/RuleSanctionList/RuleSanctionListTest.t.sol
rename to test/RuleSanctionsList/RuleSanctionsListTest.t.sol
diff --git a/test/RuleWhitelist/RuleWhitelistRemove.t.sol b/test/RuleWhitelist/RuleWhitelistRemove.t.sol
index 470f6382..6a13e8fb 100644
--- a/test/RuleWhitelist/RuleWhitelistRemove.t.sol
+++ b/test/RuleWhitelist/RuleWhitelistRemove.t.sol
@@ -20,8 +20,9 @@ contract RuleWhitelistRemoveTest is Test, HelperContract {
address[] memory whitelist = new address[](2);
whitelist[0] = ADDRESS1;
whitelist[1] = ADDRESS2;
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.AddAddresses(whitelist, 2, 0); // both new
vm.prank(WHITELIST_OPERATOR_ADDRESS);
- emit IAddressList.AddAddresses(whitelist);
(resCallBool,) = address(ruleWhitelist).call(abi.encodeWithSignature("addAddresses(address[])", whitelist));
// Assert
resUint256 = ruleWhitelist.listedAddressCount();
@@ -43,8 +44,9 @@ contract RuleWhitelistRemoveTest is Test, HelperContract {
assertEq(resBool, true);
// Act
- vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ vm.expectEmit(true, true, true, true);
emit IAddressList.RemoveAddress(ADDRESS1);
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
ruleWhitelist.removeAddress(ADDRESS1);
// Assert
@@ -69,8 +71,9 @@ contract RuleWhitelistRemoveTest is Test, HelperContract {
assertEq(resBool, true);
// Act
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.RemoveAddresses(whitelist, 2, 0); // both present
vm.prank(WHITELIST_OPERATOR_ADDRESS);
- emit IAddressList.RemoveAddresses(whitelist);
(resCallBool,) = address(ruleWhitelist).call(abi.encodeWithSignature("removeAddresses(address[])", whitelist));
// Assert
assertEq(resCallBool, true);
@@ -89,8 +92,8 @@ contract RuleWhitelistRemoveTest is Test, HelperContract {
vm.expectRevert(RuleAddressSet_AddressNotFound.selector);
// Act
+ // No event is expected: the call reverts, so nothing is emitted.
vm.prank(WHITELIST_OPERATOR_ADDRESS);
- emit IAddressList.RemoveAddress(ADDRESS1);
ruleWhitelist.removeAddress(ADDRESS1);
// Assert
@@ -110,8 +113,10 @@ contract RuleWhitelistRemoveTest is Test, HelperContract {
whitelistRemove[2] = ADDRESS3;
// Act
+ // The point of carrying the effect in the event: 3 submitted, only 2 were present.
+ vm.expectEmit(true, true, true, true);
+ emit IAddressList.RemoveAddresses(whitelistRemove, 2, 1);
vm.prank(WHITELIST_OPERATOR_ADDRESS);
- emit IAddressList.RemoveAddresses(whitelistRemove);
(resCallBool,) =
address(ruleWhitelist).call(abi.encodeWithSignature("removeAddresses(address[])", whitelistRemove));
// Assert
diff --git a/test/RuleWhitelist/WhitelistWrapper.t.sol b/test/RuleWhitelist/WhitelistWrapper.t.sol
index e2b3b60a..c281dd6d 100644
--- a/test/RuleWhitelist/WhitelistWrapper.t.sol
+++ b/test/RuleWhitelist/WhitelistWrapper.t.sol
@@ -335,6 +335,36 @@ contract CMTATIntegrationWhitelistWrapper is Test, HelperContract {
assertFalse(ruleWhitelistWrapper.isVerified(ADDRESS1));
}
+ /**
+ * @notice A target listed in several child rules must be counted as resolved exactly once.
+ * @dev ADDRESS1 sits in two children while ADDRESS2 sits only in the third, so the scan
+ * necessarily re-encounters ADDRESS1 as already resolved before reaching child 3. This
+ * pins the `!result[j]` guard in `_detectTransferRestrictionForTargets`: without it the
+ * second listing of ADDRESS1 would decrement the resolved counter a second time, driving
+ * it to zero and breaking out of the scan before child 3 is ever consulted -- ADDRESS2
+ * would then be reported unlisted and a valid transfer rejected.
+ */
+ function testDetectTransferRestrictionOkWhenAddressListedInSeveralChildRules() public {
+ // Arrange: ADDRESS1 in child 1 AND child 2, ADDRESS2 only in child 3.
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ ruleWhitelist.addAddress(ADDRESS1);
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ ruleWhitelist2.addAddress(ADDRESS1);
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ ruleWhitelist3.addAddress(ADDRESS2);
+
+ // Act
+ resUint8 = ruleWhitelistWrapper.detectTransferRestriction(ADDRESS1, ADDRESS2, 20);
+ // Assert
+ assertEq(resUint8, NO_ERROR);
+
+ // The same holds for the spender overload, which resolves three targets instead of two.
+ vm.prank(WHITELIST_OPERATOR_ADDRESS);
+ ruleWhitelist2.addAddress(ADDRESS3);
+ resUint8 = ruleWhitelistWrapper.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ADDRESS2, 20);
+ assertEq(resUint8, NO_ERROR);
+ }
+
function testIsVerifiedWithNoChildRules() public {
RuleWhitelistWrapper emptyWrapper =
new RuleWhitelistWrapper(WHITELIST_OPERATOR_ADDRESS, ZERO_ADDRESS, true, true);
diff --git a/test/ThreatModel/ThreatModelTests.t.sol b/test/ThreatModel/ThreatModelTests.t.sol
index d6929664..c618e877 100644
--- a/test/ThreatModel/ThreatModelTests.t.sol
+++ b/test/ThreatModel/ThreatModelTests.t.sol
@@ -22,7 +22,7 @@ import {RuleMintAllowance} from "src/rules/operation/RuleMintAllowance.sol";
/**
* @title ThreatModelTests
- * @notice Proof-of-concept tests backing the findings recorded in THREAT_MODEL.md / RESULT.md.
+ * @notice Proof-of-concept tests backing the findings recorded in `CLAUDE_AUDIT.md`.
* @dev Each test names the threat ID it exercises. Tests that assert a *current, undesirable*
* behaviour are named `*_CurrentBehaviour` so a future fix flags them for update.
*/
@@ -507,6 +507,60 @@ contract ThreatModelTests is Test, HelperContract {
rule.transferred(MINTER, ZERO_ADDRESS, ADDRESS2, 100);
}
+ /**
+ * @notice MA-1, one level up: the hardcoded "allowed" is not confined to the rule. It propagates
+ * through the RuleEngine's aggregate and out to the token's own ERC-1404 views, which is
+ * the API an integrator actually calls (`CLAUDE_ANALYSIS.md` F-6).
+ * @dev `RuleEngineBase._detectTransferRestriction` aggregates by calling each rule's 3-argument
+ * view and returning the first non-zero code; this rule always contributes `0`. CMTAT's
+ * `ValidationModuleERC1404` then forwards the token's views to the engine. The 4-argument
+ * chain is unaffected at every level and carries the real answer.
+ *
+ * `_CurrentBehaviour`: this asserts what the audit considers wrong. If the rule, the engine
+ * or CMTAT is ever changed to close the gap, this test must fail — at which point update it
+ * together with `CLAUDE_ANALYSIS.md` F-6, `CLAUDE_AUDIT.md` F-7 and the two documentation tables.
+ */
+ function test_MA1_EngineAndTokenInheritTheHardcodedAllowedView_CurrentBehaviour() public {
+ cmtatDeployment = new CMTATDeployment();
+ cmtatContract = cmtatDeployment.cmtat();
+
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ ruleEngineMock = new RuleEngine(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, address(cmtatContract));
+ RuleMintAllowance rule = new RuleMintAllowance(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(address(ruleEngineMock));
+ ruleEngineMock.addRule(rule);
+ cmtatContract.setRuleEngine(ruleEngineMock);
+ cmtatContract.grantRole(keccak256("MINTER_ROLE"), MINTER);
+ vm.stopPrank();
+
+ // MINTER has no quota at all: every mint by them will revert.
+ assertEq(rule.mintAllowance(MINTER), 0);
+
+ // ENGINE level — the aggregate reports "allowed".
+ assertEq(ruleEngineMock.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 100), TRANSFER_OK);
+ assertTrue(ruleEngineMock.canTransfer(ZERO_ADDRESS, ADDRESS2, 100));
+
+ // TOKEN level — CMTAT forwards to the engine, so it reports "allowed" too.
+ assertEq(cmtatContract.detectTransferRestriction(ZERO_ADDRESS, ADDRESS2, 100), TRANSFER_OK);
+ assertTrue(cmtatContract.canTransfer(ZERO_ADDRESS, ADDRESS2, 100));
+
+ // The 4-argument chain carries the real answer at BOTH levels.
+ assertEq(
+ ruleEngineMock.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS2, 100),
+ CODE_MINTER_ALLOWANCE_EXCEEDED
+ );
+ assertFalse(ruleEngineMock.canTransferFrom(MINTER, ZERO_ADDRESS, ADDRESS2, 100));
+ assertEq(
+ cmtatContract.detectTransferRestrictionFrom(MINTER, ZERO_ADDRESS, ADDRESS2, 100),
+ CODE_MINTER_ALLOWANCE_EXCEEDED
+ );
+
+ // Enforcement agrees with the 4-arg views, not with the 3-arg ones.
+ vm.prank(MINTER);
+ vm.expectRevert();
+ cmtatContract.mint(ADDRESS2, 100);
+ }
+
/**
* @notice MA-1: quota accounting never underflows and always matches consumed amounts.
*/
diff --git a/test/Version.t.sol b/test/Version.t.sol
index e810c4cc..cdb5dcf3 100644
--- a/test/Version.t.sol
+++ b/test/Version.t.sol
@@ -8,12 +8,26 @@ import {RuleBlacklist} from "src/rules/validation/deployment/RuleBlacklist.sol";
import {RuleSanctionsList} from "src/rules/validation/deployment/RuleSanctionsList.sol";
import {ISanctionsList} from "src/rules/interfaces/ISanctionsList.sol";
import {RuleMaxTotalSupply} from "src/rules/validation/deployment/RuleMaxTotalSupply.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
+import {RuleMaxBalance} from "src/rules/validation/deployment/RuleMaxBalance.sol";
+import {RuleMaxBalanceOwnable2Step} from "src/rules/validation/deployment/RuleMaxBalanceOwnable2Step.sol";
+import {BalanceOfMock} from "src/mocks/BalanceOfMock.sol";
import {RuleWhitelistWrapper} from "src/rules/validation/deployment/RuleWhitelistWrapper.sol";
import {RuleERC2980} from "src/rules/validation/deployment/RuleERC2980.sol";
import {RuleConditionalTransferLight} from "src/rules/operation/RuleConditionalTransferLight.sol";
+import {RuleConditionalTransferLightMultiToken} from "src/rules/operation/RuleConditionalTransferLightMultiToken.sol";
+import {RuleMintAllowance} from "src/rules/operation/RuleMintAllowance.sol";
+import {RuleSpenderWhitelist} from "src/rules/validation/deployment/RuleSpenderWhitelist.sol";
+import {RuleReceiverWhitelist} from "src/rules/validation/deployment/RuleReceiverWhitelist.sol";
+import {RuleIdentityRegistry} from "src/rules/validation/deployment/RuleIdentityRegistry.sol";
+import {RuleChainlinkPoR} from "src/rules/validation/deployment/RuleChainlinkPoR.sol";
+import {IdentityRegistryWhitelist} from "src/registry/IdentityRegistryWhitelist.sol";
+import {AggregatorV3Interface} from "src/rules/interfaces/AggregatorV3Interface.sol";
+import {AggregatorV3Mock} from "src/mocks/AggregatorV3Mock.sol";
+import {TotalSupplyDecimalsMock} from "src/mocks/TotalSupplyDecimalsMock.sol";
contract VersionTest is Test, HelperContract {
- string constant EXPECTED_VERSION = "0.4.0";
+ string constant EXPECTED_VERSION = "0.5.0";
function testVersionRuleWhitelist() public {
RuleWhitelist rule = new RuleWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, true, false);
@@ -32,7 +46,18 @@ contract VersionTest is Test, HelperContract {
}
function testVersionRuleMaxTotalSupply() public {
- RuleMaxTotalSupply rule = new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, ADDRESS1, 0);
+ RuleMaxTotalSupply rule = new RuleMaxTotalSupply(DEFAULT_ADMIN_ADDRESS, address(new TotalSupplyMock()), 0);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleMaxBalance() public {
+ RuleMaxBalance rule = new RuleMaxBalance(DEFAULT_ADMIN_ADDRESS, address(new BalanceOfMock()), 0);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleMaxBalanceOwnable2Step() public {
+ RuleMaxBalanceOwnable2Step rule =
+ new RuleMaxBalanceOwnable2Step(DEFAULT_ADMIN_ADDRESS, address(new BalanceOfMock()), 0);
assertEq(rule.version(), EXPECTED_VERSION);
}
@@ -50,4 +75,47 @@ contract VersionTest is Test, HelperContract {
RuleConditionalTransferLight rule = new RuleConditionalTransferLight(DEFAULT_ADMIN_ADDRESS);
assertEq(rule.version(), EXPECTED_VERSION);
}
+
+ function testVersionRuleSpenderWhitelist() public {
+ RuleSpenderWhitelist rule = new RuleSpenderWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleReceiverWhitelist() public {
+ RuleReceiverWhitelist rule = new RuleReceiverWhitelist(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleIdentityRegistry() public {
+ RuleIdentityRegistry rule = new RuleIdentityRegistry(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, false, false);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleChainlinkPoR() public {
+ TotalSupplyDecimalsMock token = new TotalSupplyDecimalsMock(18);
+ AggregatorV3Mock feed = new AggregatorV3Mock(8, 1000 * 1e8);
+ RuleChainlinkPoR rule = new RuleChainlinkPoR(
+ DEFAULT_ADMIN_ADDRESS, address(token), 18, AggregatorV3Interface(address(feed)), 1 days
+ );
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleConditionalTransferLightMultiToken() public {
+ RuleConditionalTransferLightMultiToken rule = new RuleConditionalTransferLightMultiToken(DEFAULT_ADMIN_ADDRESS);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ function testVersionRuleMintAllowance() public {
+ RuleMintAllowance rule = new RuleMintAllowance(DEFAULT_ADMIN_ADDRESS);
+ assertEq(rule.version(), EXPECTED_VERSION);
+ }
+
+ /**
+ * @notice `IdentityRegistryWhitelist` is not a rule, but it is a deployable production contract
+ * wired into an ERC-3643 token's identity slot, so it carries the same version string.
+ */
+ function testVersionIdentityRegistryWhitelist() public {
+ IdentityRegistryWhitelist registry = new IdentityRegistryWhitelist(DEFAULT_ADMIN_ADDRESS);
+ assertEq(registry.version(), EXPECTED_VERSION);
+ }
}
diff --git a/test/VirtualHooks/VirtualHookOverride.t.sol b/test/VirtualHooks/VirtualHookOverride.t.sol
new file mode 100644
index 00000000..f90af898
--- /dev/null
+++ b/test/VirtualHooks/VirtualHookOverride.t.sol
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {Test} from "forge-std/Test.sol";
+import {HelperContract} from "../HelperContract.sol";
+import {TotalSupplyMock} from "src/mocks/TotalSupplyMock.sol";
+import {
+ BlacklistQuarantineHarness,
+ ConditionalTransferLightCustomExecutorHarness,
+ ERC2980SelfWhitelistBlockHarness,
+ IdentityRegistryPinnedHarness,
+ MaxTotalSupplyCappedSetterHarness
+} from "src/mocks/harness/VirtualHookOverrideHarnesses.sol";
+
+/**
+ * @title VirtualHookOverride
+ * @notice Pins the `virtual` convention for functions that used to be non-`virtual`
+ * (`CLAUDE_ANALYSIS.md` E-1 internal hooks, E-2 `canTransfer`, E-3 public mutating functions).
+ * @dev Two layers of protection. Compiling `VirtualHookOverrideHarnesses.sol` at all proves the
+ * functions are overridable -- removing `virtual` breaks the build. The assertions below prove
+ * the overrides are actually *reached*, which a compile-only check would not.
+ * E-3 coverage is representative rather than exhaustive; see the harness file for why.
+ */
+contract VirtualHookOverride is Test, HelperContract {
+ address private constant SOLE_EXECUTOR = address(0xE1);
+ address private constant QUARANTINED = address(0xC0FFEE);
+
+ /*//////////////////////////////////////////////////////////////
+ _authorizeTransferExecution (operation rule)
+ //////////////////////////////////////////////////////////////*/
+
+ function testCustomExecutorPolicyReplacesTheBoundTokenCheck() public {
+ ConditionalTransferLightCustomExecutorHarness rule =
+ new ConditionalTransferLightCustomExecutorHarness(DEFAULT_ADMIN_ADDRESS, SOLE_EXECUTOR);
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(ADDRESS3);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, 10);
+ vm.stopPrank();
+
+ // The bound token would be authorized by the base policy; the override rejects it.
+ vm.expectRevert(
+ abi.encodeWithSelector(ConditionalTransferLightCustomExecutorHarness.NotTheSoleExecutor.selector, ADDRESS3)
+ );
+ vm.prank(ADDRESS3);
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+
+ // The custom executor is not the bound token, yet the override authorizes it.
+ vm.prank(SOLE_EXECUTOR);
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+ assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, 10), 0);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ _detectTransferRestriction / ...From (validation rule)
+ //////////////////////////////////////////////////////////////*/
+
+ function testSubclassCanExtendTheBlacklistRestrictionHooks() public {
+ BlacklistQuarantineHarness rule =
+ new BlacklistQuarantineHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, QUARANTINED);
+ uint8 quarantinedCode = rule.CODE_QUARANTINED();
+
+ // The base rule still applies: an unlisted, unquarantined pair passes.
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+
+ // The override adds its own rejection on both endpoints...
+ assertEq(rule.detectTransferRestriction(QUARANTINED, ADDRESS2, 10), quarantinedCode);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, QUARANTINED, 10), quarantinedCode);
+
+ // ...and on the spender, through the `From` hook.
+ assertEq(rule.detectTransferRestrictionFrom(QUARANTINED, ADDRESS1, ADDRESS2, 10), quarantinedCode);
+ assertEq(rule.detectTransferRestrictionFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+
+ // `super` still reaches the base implementation: a blacklisted sender keeps its own code.
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS1);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), CODE_ADDRESS_FROM_IS_BLACKLISTED);
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ canTransfer (both overloads)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice `canTransfer` was the only non-`virtual` view in `RuleTransferValidation`, and its
+ * ERC-7943 twin the only one in `RuleNFTAdapter` (`CLAUDE_ANALYSIS.md` E-2).
+ * @dev The harness makes both overloads return `false` unconditionally, contradicting
+ * `detectTransferRestriction`. If the override were not in effect the inherited
+ * implementation would delegate to the restriction hook and return `true` here, so this
+ * distinguishes a reached override from a silently ignored one.
+ */
+ function testSubclassCanOverrideBothCanTransferOverloads() public {
+ BlacklistQuarantineHarness rule =
+ new BlacklistQuarantineHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, QUARANTINED);
+
+ // The restriction hook still says the transfer is fine...
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 10), TRANSFER_OK);
+ assertEq(rule.detectTransferRestriction(ADDRESS1, ADDRESS2, 0, 10), TRANSFER_OK);
+
+ // ...but the overridden views answer for themselves.
+ assertFalse(rule.canTransfer(ADDRESS1, ADDRESS2, 10));
+ assertFalse(rule.canTransfer(ADDRESS1, ADDRESS2, 0, 10));
+
+ // `canTransferFrom` was already `virtual` and is not overridden here, so it still tracks
+ // the restriction hook -- confirming only the intended functions changed.
+ assertTrue(rule.canTransferFrom(ADDRESS3, ADDRESS1, ADDRESS2, 10));
+ }
+
+ /*//////////////////////////////////////////////////////////////
+ public mutating functions (E-3, representative)
+ //////////////////////////////////////////////////////////////*/
+
+ /**
+ * @notice One override per family of the 27 public mutating functions made `virtual` by E-3.
+ * @dev Sampled, not exhaustive: `virtual` is applied per function, so a regression on an
+ * uncovered sibling would still slip through. See the harness file's note.
+ */
+ function testSubclassCanOverrideTheApprovalAndTransferredEntrypoints() public {
+ ConditionalTransferLightCustomExecutorHarness rule =
+ new ConditionalTransferLightCustomExecutorHarness(DEFAULT_ADMIN_ADDRESS, SOLE_EXECUTOR);
+ vm.startPrank(DEFAULT_ADMIN_ADDRESS);
+ rule.bindToken(ADDRESS3);
+ rule.approveTransfer(ADDRESS1, ADDRESS2, 10);
+ vm.stopPrank();
+ assertEq(rule.approveTransferOverrideCalls(), 1);
+ assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, 10), 1, "base logic must still run");
+
+ vm.prank(SOLE_EXECUTOR);
+ rule.transferred(ADDRESS1, ADDRESS2, 10);
+ assertEq(rule.transferredOverrideCalls(), 1);
+ assertEq(rule.approvedCount(ADDRESS1, ADDRESS2, 10), 0, "base logic must still run");
+ }
+
+ function testSubclassCanOverrideAnAddressSetWrite() public {
+ BlacklistQuarantineHarness rule =
+ new BlacklistQuarantineHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, QUARANTINED);
+
+ vm.expectRevert(BlacklistQuarantineHarness.CannotListQuarantined.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(QUARANTINED);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addAddress(ADDRESS1);
+ assertTrue(rule.isAddressListed(ADDRESS1), "base logic must still run");
+ }
+
+ function testSubclassCanOverrideARuleConfigurationSetter() public {
+ TotalSupplyMock token = new TotalSupplyMock();
+ MaxTotalSupplyCappedSetterHarness rule =
+ new MaxTotalSupplyCappedSetterHarness(DEFAULT_ADMIN_ADDRESS, address(token), 100);
+
+ vm.expectRevert(abi.encodeWithSelector(MaxTotalSupplyCappedSetterHarness.AboveHardCeiling.selector, 1_000_001));
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMaxTotalSupply(1_000_001);
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setMaxTotalSupply(500);
+ assertEq(rule.maxTotalSupply(), 500, "base logic must still run");
+ }
+
+ function testSubclassCanOverrideTheIdentityRegistrySetter() public {
+ IdentityRegistryPinnedHarness rule =
+ new IdentityRegistryPinnedHarness(DEFAULT_ADMIN_ADDRESS, ADDRESS3, false, false);
+
+ vm.expectRevert(IdentityRegistryPinnedHarness.RegistryIsPinned.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.setIdentityRegistry(ADDRESS1);
+
+ assertEq(address(rule.identityRegistry()), ADDRESS3);
+ }
+
+ function testSubclassCanOverrideAnErc2980ListWrite() public {
+ ERC2980SelfWhitelistBlockHarness rule =
+ new ERC2980SelfWhitelistBlockHarness(DEFAULT_ADMIN_ADDRESS, ZERO_ADDRESS, true);
+
+ vm.expectRevert(ERC2980SelfWhitelistBlockHarness.CannotWhitelistTheRule.selector);
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addWhitelistAddress(address(rule));
+
+ vm.prank(DEFAULT_ADMIN_ADDRESS);
+ rule.addWhitelistAddress(ADDRESS1);
+ assertTrue(rule.isWhitelisted(ADDRESS1), "base logic must still run");
+ }
+}
diff --git a/test/invariant/RuleInvariants.t.sol b/test/invariant/RuleInvariants.t.sol
index bebdff7a..0b946a2b 100644
--- a/test/invariant/RuleInvariants.t.sol
+++ b/test/invariant/RuleInvariants.t.sol
@@ -10,7 +10,7 @@ import {MintAllowanceHandler} from "./MintAllowanceHandler.sol";
/**
* @title ConditionalTransferInvariants
* @notice Stateful invariant suite over {RuleConditionalTransferLight}'s approval state machine.
- * @dev Covers INV-5 (approvals are conserved and never underflow) — see TEST_IMPROVEMENT.md I-10b.
+ * @dev Covers INV-5 (approvals are conserved and never underflow).
*/
contract ConditionalTransferInvariants is Test {
address private constant ADMIN = address(1);
@@ -69,7 +69,7 @@ contract ConditionalTransferInvariants is Test {
/**
* @title MintAllowanceInvariants
* @notice Stateful invariant suite over {RuleMintAllowance}'s quota accounting.
- * @dev Covers INV-7 (quota is exact, monotonically consumed, never underflows) — TEST_IMPROVEMENT.md I-10b.
+ * @dev Covers INV-7 (quota is exact, monotonically consumed, never underflows).
*/
contract MintAllowanceInvariants is Test {
address private constant ADMIN = address(1);
diff --git a/test/utils/onchainid/interface/IClaimIssuer.sol b/test/utils/onchainid/interface/IClaimIssuer.sol
new file mode 100644
index 00000000..38854be1
--- /dev/null
+++ b/test/utils/onchainid/interface/IClaimIssuer.sol
@@ -0,0 +1,31 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+import {IIdentity} from "./IIdentity.sol";
+
+/**
+ * @title IClaimIssuer — minimal stand-in for ONCHAINID's `IClaimIssuer`.
+ * @notice Imported by ERC-3643's trusted-issuers registry interface. Within the compile set it is
+ * used as a type in most places, and *called* in one: `IdentityRegistry.isVerified` invokes
+ * `isClaimValid` on the trusted issuer of each required claim topic.
+ *
+ * @dev See {IIdentity} for why these stubs exist and the remapping that wires them in.
+ *
+ * WARNING: not the real ONCHAINID interface. The genuine `IClaimIssuer` adds claim revocation and
+ * signature validation. Do not import this outside the ERC-3643 build context.
+ */
+interface IClaimIssuer is IIdentity {
+ /**
+ * @notice Returns whether a claim issued by this issuer is currently valid.
+ * @dev Called by `IdentityRegistry.isVerified` for every required claim topic.
+ * @param _identity The identity the claim is about.
+ * @param _claimTopic The claim topic.
+ * @param _sig The claim signature.
+ * @param _data The claim data.
+ * @return True when the claim is valid.
+ */
+ function isClaimValid(IIdentity _identity, uint256 _claimTopic, bytes calldata _sig, bytes calldata _data)
+ external
+ view
+ returns (bool);
+}
diff --git a/test/utils/onchainid/interface/IIdentity.sol b/test/utils/onchainid/interface/IIdentity.sol
new file mode 100644
index 00000000..6deceff0
--- /dev/null
+++ b/test/utils/onchainid/interface/IIdentity.sol
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: MPL-2.0
+pragma solidity ^0.8.20;
+
+/**
+ * @title IIdentity — minimal stand-in for ONCHAINID's `IIdentity`.
+ * @notice ERC-3643 imports `@onchain-id/solidity/contracts/interface/IIdentity.sol`. That package
+ * is an npm dependency, not a git submodule, so it is not vendored here. This file supplies the
+ * members ERC-3643 actually *calls* — `keyHasPurpose` in `Token.recoveryAddress`, and `getClaim` in
+ * `IdentityRegistry.isVerified` — and is wired in through a context-scoped remapping that applies to
+ * `lib/ERC-3643/` only.
+ *
+ * @dev Same approach as `src/rules/interfaces/AggregatorV3Interface.sol`: redeclare the slice of a
+ * third-party interface that is genuinely used rather than vendor the whole package. Everywhere
+ * else in ERC-3643, `IIdentity` appears only as a parameter or event type, and a contract type
+ * canonicalises to `address` in the ABI, so the declared members do not affect any selector.
+ *
+ * WARNING: this is NOT the real ONCHAINID interface. The genuine `IIdentity` extends ERC-734
+ * (key management) and ERC-735 (claims) with a much larger surface. Do not treat this file as a
+ * specification of ONCHAINID, and do not import it outside the ERC-3643 build context.
+ */
+interface IIdentity {
+ /**
+ * @notice Returns whether a key holds a given purpose.
+ * @param _key The key, `keccak256(abi.encode(walletAddress))` in ERC-3643's usage.
+ * @param _purpose The ERC-734 purpose (1 = MANAGEMENT).
+ * @return True if the key holds the purpose.
+ */
+ function keyHasPurpose(bytes32 _key, uint256 _purpose) external view returns (bool);
+
+ /**
+ * @notice Returns a claim held by this identity (ERC-735).
+ * @dev Called by `IdentityRegistry.isVerified` once the token requires at least one claim topic.
+ * @param _claimId `keccak256(abi.encode(issuer, topic))`.
+ * @return topic The claim topic.
+ * @return scheme The signature scheme.
+ * @return issuer The claim issuer.
+ * @return signature The claim signature.
+ * @return data The claim data.
+ * @return uri A URI pointing at the claim.
+ */
+ function getClaim(bytes32 _claimId)
+ external
+ view
+ returns (
+ uint256 topic,
+ uint256 scheme,
+ address issuer,
+ bytes memory signature,
+ bytes memory data,
+ string memory uri
+ );
+}