From 1233b42ad9b45ff5f53eec78c3bc23fba13e879a Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:11:16 +0200 Subject: [PATCH 01/19] Update to CMTAT v3.3.0-rc1 and other related librares, implement ERC-1643 rework --- .gitmodules | 3 + AGENTS.md | 88 ++++++ CHANGELOG.md | 64 +++- CLAUDE.md | 88 ++++++ README.md | 78 ++++- doc/ERCSpecification/ERC-1643-proposition.md | 129 ++++++++ doc/ERCSpecification/erc-1643.md | 158 ++++++++++ doc/slither-report.md | 38 --- foundry.lock | 20 ++ foundry.toml | 4 +- lib/CMTAT | 2 +- lib/RuleEngine | 1 + lib/forge-std | 2 +- lib/openzeppelin-contracts | 2 +- lib/openzeppelin-contracts-upgradeable | 2 +- remappings.txt | 1 + src/DocumentEngine.sol | 66 +++- src/DocumentEngineInvariant.sol | 35 ++- test/DocumentEngine.t.sol | 313 +++++++++++-------- 19 files changed, 877 insertions(+), 217 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 doc/ERCSpecification/ERC-1643-proposition.md create mode 100644 doc/ERCSpecification/erc-1643.md delete mode 100644 doc/slither-report.md create mode 100644 foundry.lock create mode 160000 lib/RuleEngine diff --git a/.gitmodules b/.gitmodules index 415e443..4d17ab7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/openzeppelin-contracts-upgradeable"] path = lib/openzeppelin-contracts-upgradeable url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "lib/RuleEngine"] + path = lib/RuleEngine + url = https://github.com/CMTA/RuleEngine diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ca68820 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# DocumentEngine — Agent Guide + +> **Note — keep in sync:** `AGENTS.md` and `CLAUDE.md` must always be **identical**. +> Any edit to one must be applied verbatim to the other. + +> **Note — commit messages:** After each group of modifications or each feature +> added, always provide a **one-line GitHub commit message** (Conventional-Commits +> style, e.g. `feat: add token binding`, `fix: correct event args`, `docs: update README`). + +## What this project is + +`DocumentEngine` is a standalone smart contract that manages documents on-chain +through **ERC-1643** on behalf of **several** other smart contracts (e.g. CMTAT +tokens). Using an external engine keeps each token small and lets one operator +manage documents for a whole fleet of tokens. + +A document is `{ string uri, bytes32 documentHash, uint256 lastModified }`, +addressed by a `bytes32` name. + +## Key concepts + +- **Two management paths (both active at once):** + - **Admin path** — `DOCUMENT_MANAGER_ROLE`. Address-scoped overloads + (`setDocument(address,...)`, `removeDocument(address,...)`, batch variants) + manage documents for any contract. + - **Bound-token path** — `TOKEN_CONTRACT_ROLE` (the CMTA RuleEngine binding + pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, + `removeDocument(name)`) let a bound token manage its **own** namespace + (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. +- **Events:** every write emits the standard `IERC1643` events **and** the optional + `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the + `smartContract` address). See `ERC-1643-proposition.md`. +- **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. +- **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the + `hasRole` override). +- **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's + `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded + keyed by the token address). Standard CMTAT standalone tokens store documents + on-chain instead and do **not** use this engine. + +## File tree + +``` +src/ +├── DocumentEngine.sol # Main contract: ERC-1643 impl, both management +│ # paths, batch functions, ERC-2771, access control +└── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, + # TOKEN_CONTRACT_ROLE) and the optional multi-token events + +test/ +└── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, + # bound-token path, batch ops, CMTAT integration + # (CMTATDocumentEngineMock built on DocumentEngineModule) +``` + +Other important files: + +- `foundry.toml` — solc `0.8.34`, `evm_version = prague` (required by CMTAT v3). +- `remappings.txt` — `CMTAT/`, `RuleEngine/`, `OZ/`, `@openzeppelin/contracts-upgradeable/`. +- `CHANGELOG.md` — semver history; update on every release (current: `v0.4.0`). +- `ERC-1643-proposition.md` — proposed optional multi-token events / extension. +- `README.md` — full documentation and Surya schema. +- `doc/` — Surya diagrams/reports (`doc/script/`), Slither report, coverage. +- `lib/` — submodules: `CMTAT`, `RuleEngine`, `openzeppelin-contracts(-upgradeable)`, `forge-std`. + +## Dependencies (tested versions) + +- CMTAT `v3.3.0-rc1`, RuleEngine `v2.1.0` +- OpenZeppelin Contracts / Contracts Upgradeable `v5.6.1` +- Solidity `0.8.34`, Foundry + +## Common commands + +```bash +forge build # compile +forge test # run the test suite +forge fmt # format +forge test --gas-report +``` + +## Conventions + +- The contract `VERSION` constant (in `src/DocumentEngine.sol`) must match the + latest `CHANGELOG.md` entry on release. +- Bump `MAJOR` on incompatible proxy-storage / external-library or API changes, + `MINOR` for backward-compatible features, `PATCH` for backward-compatible fixes. +- A bound token can only ever affect its **own** document namespace — never break + that isolation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 199d877..576d56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,68 @@ # CHANGELOG -Please follow https://changelog.md/ conventions. +Please follow [https://changelog.md](https://changelog.md) conventions and the other conventions below + +## Semantic Version 2.0.0 + +Given a version number MAJOR.MINOR.PATCH, increment the: + +1. MAJOR version when the new version makes: + - Incompatible proxy **storage** change internally or through the upgrade of an external library (OpenZeppelin) + - A significant change in external APIs (public/external functions) or in the internal architecture +2. MINOR version when the new version adds functionality in a backward compatible manner +3. PATCH version when the new version makes backward compatible bug fixes + +See [https://semver.org](https://semver.org) + +## Type of changes + +- `Added` for new features. +- `Changed` for changes in existing functionality. +- `Deprecated` for soon-to-be removed features. +- `Removed` for now removed features. +- `Fixed` for any bug fixes. +- `Security` in case of vulnerabilities. + +Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) + +## Checklist + +> Before a new release, perform the following tasks + +- Code: Update the version name in the `Version` core module, variable VERSION +- Run linter + +> npm run-script lint:all:prettier + +- Documentation + - Perform a code coverage and update the files in the corresponding directory [./doc/general/test/coverage](./doc/general/test/coverage) + - Perform an audit with several audit tools (Aderyn and Slither), update the report in the corresponding directory [./doc/audits/tools](./doc/audits/tools) + - Update surya doc by running the 3 scripts in [./doc/script](./doc/script) + + - Update changelog + +## v0.4.0 + +### Changed + +- **Dependencies** + - Upgrade CMTAT `v2.5.0-rc0` → `v3.3.0-rc1` + - Upgrade OpenZeppelin Contracts (and Contracts Upgradeable) `v5.0.2` → `v5.6.1` + - Add [CMTA/RuleEngine](https://github.com/CMTA/RuleEngine) `v2.1.0` as a submodule (binding-role reference) +- **Toolchain**: bump Solidity `0.8.26` → `0.8.34` and `evm_version` `cancun` → `prague` to match CMTAT v3 (CMTAT uses `require(cond, CustomError())`, which needs solc ≥ 0.8.27) +- **`IERC1643` (CMTAT v3) breaking changes** + - `getDocument(bytes32)` now returns a `Document` struct instead of the `(string, bytes32, uint256)` tuple. Both `getDocument` overloads updated accordingly. + - The `Document` struct and the `DocumentUpdated`/`DocumentRemoved` events are now provided by `IERC1643`; the duplicate local declarations were removed from `DocumentEngineInvariant`. + - Import path moved: `CMTAT/interfaces/engine/draft-IERC1643.sol` → `CMTAT/interfaces/tokenization/draft-IERC1643.sol`. + +### Added + +- **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. +- **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. + +### Notes / bottlenecks + +- CMTAT v3 no longer ships a *standalone* token that consumes an external document engine through its constructor; the standard token stores documents on-chain (`DocumentERC1643Module`). External-engine integration now goes through CMTAT's `DocumentEngineModule` (`setDocumentEngine`). The test suite was updated to exercise this real integration path via a minimal token built on `DocumentEngineModule`. ## v0.3.0 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca68820 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,88 @@ +# DocumentEngine — Agent Guide + +> **Note — keep in sync:** `AGENTS.md` and `CLAUDE.md` must always be **identical**. +> Any edit to one must be applied verbatim to the other. + +> **Note — commit messages:** After each group of modifications or each feature +> added, always provide a **one-line GitHub commit message** (Conventional-Commits +> style, e.g. `feat: add token binding`, `fix: correct event args`, `docs: update README`). + +## What this project is + +`DocumentEngine` is a standalone smart contract that manages documents on-chain +through **ERC-1643** on behalf of **several** other smart contracts (e.g. CMTAT +tokens). Using an external engine keeps each token small and lets one operator +manage documents for a whole fleet of tokens. + +A document is `{ string uri, bytes32 documentHash, uint256 lastModified }`, +addressed by a `bytes32` name. + +## Key concepts + +- **Two management paths (both active at once):** + - **Admin path** — `DOCUMENT_MANAGER_ROLE`. Address-scoped overloads + (`setDocument(address,...)`, `removeDocument(address,...)`, batch variants) + manage documents for any contract. + - **Bound-token path** — `TOKEN_CONTRACT_ROLE` (the CMTA RuleEngine binding + pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, + `removeDocument(name)`) let a bound token manage its **own** namespace + (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. +- **Events:** every write emits the standard `IERC1643` events **and** the optional + `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the + `smartContract` address). See `ERC-1643-proposition.md`. +- **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. +- **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the + `hasRole` override). +- **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's + `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded + keyed by the token address). Standard CMTAT standalone tokens store documents + on-chain instead and do **not** use this engine. + +## File tree + +``` +src/ +├── DocumentEngine.sol # Main contract: ERC-1643 impl, both management +│ # paths, batch functions, ERC-2771, access control +└── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, + # TOKEN_CONTRACT_ROLE) and the optional multi-token events + +test/ +└── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, + # bound-token path, batch ops, CMTAT integration + # (CMTATDocumentEngineMock built on DocumentEngineModule) +``` + +Other important files: + +- `foundry.toml` — solc `0.8.34`, `evm_version = prague` (required by CMTAT v3). +- `remappings.txt` — `CMTAT/`, `RuleEngine/`, `OZ/`, `@openzeppelin/contracts-upgradeable/`. +- `CHANGELOG.md` — semver history; update on every release (current: `v0.4.0`). +- `ERC-1643-proposition.md` — proposed optional multi-token events / extension. +- `README.md` — full documentation and Surya schema. +- `doc/` — Surya diagrams/reports (`doc/script/`), Slither report, coverage. +- `lib/` — submodules: `CMTAT`, `RuleEngine`, `openzeppelin-contracts(-upgradeable)`, `forge-std`. + +## Dependencies (tested versions) + +- CMTAT `v3.3.0-rc1`, RuleEngine `v2.1.0` +- OpenZeppelin Contracts / Contracts Upgradeable `v5.6.1` +- Solidity `0.8.34`, Foundry + +## Common commands + +```bash +forge build # compile +forge test # run the test suite +forge fmt # format +forge test --gas-report +``` + +## Conventions + +- The contract `VERSION` constant (in `src/DocumentEngine.sol`) must match the + latest `CHANGELOG.md` entry on release. +- Bump `MAJOR` on incompatible proxy-storage / external-library or API changes, + `MINOR` for backward-compatible features, `PATCH` for backward-compatible fixes. +- A bound token can only ever affect its **own** document namespace — never break + that isolation. diff --git a/README.md b/README.md index 380053e..e6e8d21 100644 --- a/README.md +++ b/README.md @@ -13,36 +13,77 @@ The ERC-1643 defines a document with three attributes: - A generic URI (represented as a `string`) that could point to a website or other document portal. - The hash of the document contents associated with it on-chain. -A smart contract needs only to implement two functions from this standard, available in the interface [IERC1643](./contracts/interfaces/engined/draft-IERC1643.sol) to get the documents from the documentEngine. +A smart contract needs only to read documents from this standard through the interface [IERC1643](./lib/CMTAT/contracts/interfaces/tokenization/draft-IERC1643.sol) to get the documents from the documentEngine. Since CMTAT v3, `getDocument` returns a `Document` struct: ```solidity interface IERC1643 { -function getDocument(bytes32 _name) external view returns (string memory , bytes32, uint256); -function getAllDocuments() external view returns (bytes32[] memory); + struct Document { + string uri; + bytes32 documentHash; + uint256 lastModified; + } + + function getDocument(bytes32 name) external view returns (Document memory document); + function getAllDocuments() external view returns (bytes32[] memory documentNames_); + function setDocument(bytes32 name, string calldata uri, bytes32 documentHash) external; + function removeDocument(bytes32 name) external; } ``` -Use an external contract for your smart contract provides two advantages: +Using an external contract for your smart contract provides two advantages: - Reduce code size of your smart contract - Allow to manage documents for several different smart contracts -Warning: +### Two ways to manage documents -Since this engine allows to set documents for several different smart contracts, the functions to set documents take one supplementary arguments than defined in the ERC-1643. +The engine supports **two management paths** at the same time: -IERC1643 +**1. Admin path (`DOCUMENT_MANAGER_ROLE`).** Since the engine manages documents +for several different smart contracts, the admin functions take one supplementary +`address smartContract` argument compared to the ERC-1643: ```solidity -function setDocument(bytes32 _name, string _uri, bytes32 _documentHash) external; +// DocumentEngine (admin overloads) +function setDocument(address smartContract, bytes32 name_, string memory uri_, bytes32 documentHash_) external; +function removeDocument(address smartContract, bytes32 name_) external; ``` -DocumentEngine +**2. Bound-token path (`TOKEN_CONTRACT_ROLE`).** This implements the standard, +single-argument ERC-1643 functions. A token is *bound* to the engine by being +granted `TOKEN_CONTRACT_ROLE` (the same binding pattern as the CMTA +[RuleEngine](https://github.com/CMTA/RuleEngine)): ```solidity -function setDocument(address smartContract,bytes32 name_,string memory uri_, bytes32 documentHash_) +documentEngine.grantRole(TOKEN_CONTRACT_ROLE, address(token)); ``` +Once bound, the token manages its **own** documents (`msg.sender` is the token); +it can never affect another contract's documents: + +```solidity +// DocumentEngine (standard ERC-1643, scoped to msg.sender) +function setDocument(bytes32 name_, string calldata uri_, bytes32 documentHash_) external; +function removeDocument(bytes32 name_) external; +``` + +### Events + +On every write, the engine emits the standard `IERC1643` events **and** the +optional `DocumentUpdatedForContract` / `DocumentRemovedForContract` events, +which additionally carry the `smartContract` (token) address so off-chain +indexers can tell which contract a document belongs to during multi-contract +operations. See [ERC-1643-proposition.md](./ERC-1643-proposition.md) for the +proposed optional standard extension. + +### Integration with CMTAT + +Since CMTAT v3, the shipped standalone tokens store documents on-chain +(`DocumentERC1643Module`) and do not consume an external engine through their +constructor. To use this engine, a CMTAT token relies on the +`DocumentEngineModule` and is wired at runtime with `setDocumentEngine(engine)`; +reads/writes are then forwarded to the engine keyed by the token address. + ## Schema @@ -69,10 +110,12 @@ function setDocument(address smartContract,bytes32 name_,string memory uri_, byt | :----------------: | :------------------: | :----------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DocumentEngine** | Implementation | IERC1643, DocumentEngineInvariant, AccessControl | | | +| **DocumentEngine** | Implementation | IERC1643, DocumentEngineInvariant, AccessControl, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | -| └ | setDocument | Public ❗️ | 🛑 | onlyRole | -| └ | removeDocument | External ❗️ | 🛑 | onlyRole | +| └ | setDocument | Public ❗️ | 🛑 | onlyRole (DOCUMENT_MANAGER_ROLE) | +| └ | removeDocument | External ❗️ | 🛑 | onlyRole (DOCUMENT_MANAGER_ROLE) | +| └ | setDocument | External ❗️ | 🛑 | onlyRole (TOKEN_CONTRACT_ROLE) | +| └ | removeDocument | External ❗️ | 🛑 | onlyRole (TOKEN_CONTRACT_ROLE) | | └ | batchSetDocuments | External ❗️ | 🛑 | onlyRole | | └ | batchSetDocuments | External ❗️ | 🛑 | onlyRole | | └ | batchRemoveDocuments | External ❗️ | 🛑 | onlyRole | @@ -112,11 +155,12 @@ Please see the OpenGSN [documentation](https://docs.opengsn.org/contracts/#recei The toolchain includes the following components, where the versions are the latest ones that we tested: - Foundry -- Solidity 0.8.26 (via solc-js) -- OpenZeppelin Contracts (submodule) [v5.0.2](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.0.2) +- Solidity 0.8.34 (via solc-js), `evm_version = prague` +- OpenZeppelin Contracts (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.6.1) - Tests - - [CMTAT v2.5.0-rc0](https://github.com/CMTA/CMTAT/releases/tag/v2.5.0-rc0) - - OpenZeppelin Contracts Upgradeable(submodule) [v5.0.2](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.0.2) + - [CMTAT v3.3.0-rc1](https://github.com/CMTA/CMTAT/releases/tag/v3.3.0-rc1) + - [RuleEngine v2.1.0](https://github.com/CMTA/RuleEngine/releases/tag/v2.1.0) (binding-role reference) + - OpenZeppelin Contracts Upgradeable (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.6.1) ## Tools diff --git a/doc/ERCSpecification/ERC-1643-proposition.md b/doc/ERCSpecification/ERC-1643-proposition.md new file mode 100644 index 0000000..e6b57df --- /dev/null +++ b/doc/ERCSpecification/ERC-1643-proposition.md @@ -0,0 +1,129 @@ +# ERC-1643 — Proposition: optional multi-token document management + +> Status: **draft / proposition**. This document proposes optional additions to +> [ERC-1643](https://github.com/ethereum/EIPs/issues/1643) motivated by the +> `DocumentEngine` implementation in this repository. It is not part of the +> standard and is provided for discussion. + +## Context + +ERC-1643 describes document management **for a single contract**: the token that +implements the interface manages its own documents. In CMTAT v3, the standard +interface is: + +```solidity +interface IERC1643 { + struct Document { + string uri; + bytes32 documentHash; + uint256 lastModified; + } + + function getDocument(bytes32 name) external view returns (Document memory document); + function getAllDocuments() external view returns (bytes32[] memory documentNames_); + function setDocument(bytes32 name, string calldata uri, bytes32 documentHash) external; + function removeDocument(bytes32 name) external; + + event DocumentUpdated(bytes32 indexed name, string uri, bytes32 documentHash); + event DocumentRemoved(bytes32 indexed name, string uri, bytes32 documentHash); +} +``` + +A **document engine** is a different use case: a single external contract manages +documents **on behalf of several tokens**, keyed by the token address. This +reduces the code size of each token and lets one operator manage documents for a +whole fleet of tokens. + +For that multi-token use case, the standard events are **insufficient**: they only +carry the document `name`. When a single transaction sets documents for several +different tokens (batch operations), an off-chain indexer cannot tell from the +event alone which token a document belongs to — the emitting contract is always +the engine, not the token. + +## Proposition 1 — Optional multi-token events + +We propose two **optional** events that mirror the standard ones but add the +`smartContract` (token) address: + +```solidity +event DocumentUpdatedForContract( + address indexed smartContract, + bytes32 indexed name, + string uri, + bytes32 documentHash +); + +event DocumentRemovedForContract( + address indexed smartContract, + bytes32 indexed name, + string uri, + bytes32 documentHash +); +``` + +An engine that manages documents for several tokens SHOULD emit these events in +addition to the standard `DocumentUpdated` / `DocumentRemoved` events. The +standard events remain emitted for full backward compatibility with consumers +that only understand ERC-1643. + +### Important limitation: no ERC-165 discoverability + +Events are **not** part of a contract's ERC-165 interface id — `supportsInterface` +only covers function selectors. Consequently **there is no on-chain way for a +consumer to know whether a given implementation emits these optional events.** +A consumer that relies on them must obtain that information out of band (e.g. +documentation, a known implementation, or the optional extension interface below). + +## Proposition 2 — Optional multi-token management extension + +For implementations that want the multi-token capability to be +programmatically discoverable (via ERC-165) and callable, we propose an optional +extension interface. Unlike the events above, function selectors **are** part of +the ERC-165 interface id, so support can be detected on-chain. + +```solidity +interface IERC1643MultiDocument is IERC1643 { + /// @notice Get a document registered for `smartContract`. + function getDocument(address smartContract, bytes32 name) + external view returns (Document memory document); + + /// @notice Get all document names registered for `smartContract`. + function getAllDocuments(address smartContract) + external view returns (bytes32[] memory documentNames_); + + /// @notice Set or update a document for `smartContract`. + function setDocument(address smartContract, bytes32 name, string calldata uri, bytes32 documentHash) + external; + + /// @notice Remove a document for `smartContract`. + function removeDocument(address smartContract, bytes32 name) + external; + + event DocumentUpdatedForContract( + address indexed smartContract, + bytes32 indexed name, + string uri, + bytes32 documentHash + ); + event DocumentRemovedForContract( + address indexed smartContract, + bytes32 indexed name, + string uri, + bytes32 documentHash + ); +} +``` + +The single-argument functions inherited from `IERC1643` keep their standard +meaning: they operate on the caller (`msg.sender`) namespace, i.e. a token +managing its own documents. The address-scoped functions add the operator / +multi-token capability. + +## How this repository implements the proposition + +The `DocumentEngine` in this repository already follows Proposition 1: every +write emits both the standard event and the `...ForContract` variant. It also +provides the address-scoped functions of Proposition 2 (under +`DOCUMENT_MANAGER_ROLE`) and the single-argument, `msg.sender`-scoped functions +(under `TOKEN_CONTRACT_ROLE`, the RuleEngine binding pattern), though it does not +yet formally declare/expose an `IERC1643MultiDocument` interface id. diff --git a/doc/ERCSpecification/erc-1643.md b/doc/ERCSpecification/erc-1643.md new file mode 100644 index 0000000..20cc073 --- /dev/null +++ b/doc/ERCSpecification/erc-1643.md @@ -0,0 +1,158 @@ +--- +eip: 1643 +title: Document Management for Security Tokens +description: Interface to attach, update, remove, and enumerate legal or operational documents for token contracts. +author: Adam Dossa (@adamdossa), Pablo Ruiz (@pabloruiz55), Fabian Vogelsteller (@frozeman), Stephane Gosselin (@thegostep), Ryan Sauge (@rya-sge) +discussions-to: https://ethereum-magicians.org/t/erc-1643-document-management-standard-erc-1400/27437 +status: Draft +type: Standards Track +category: ERC +created: 2018-09-09 +--- + +## Abstract + +This ERC defines a standard interface for associating documents with a token contract and for notifying off-chain systems when those documents change. Documents can represent legal agreements, offering materials, disclosures, or other issuer-provided references needed for security token operations. + +## Motivation + +Security tokens commonly represent assets with legal rights and obligations that depend on external documents. Wallets, exchanges, custodians, and compliance tools need a predictable way to discover those documents and track updates. + +Without a standard, each implementation exposes different storage and retrieval methods, increasing integration cost and operational risk. A common interface allows ecosystem participants to read and monitor document metadata consistently. + +Within security token frameworks, the document management component highlights that security tokens usually have associated documentation such as offering documents and legend details. The ability to set, remove, and retrieve these documents, with events emitted on those actions, allows investors and integrators to remain up to date. + +This ERC intentionally does not define an on-chain mechanism for investors to attest they have read or agreed to any document. + +Although originally designed for security tokens built on [ERC-20](./eip-20.md) as part of a broader real-world asset token suite, this interface is not restricted to that context. It can be adopted by any token standard, including [ERC-721](./eip-721.md) non-fungible tokens and [ERC-1155](./eip-1155.md) multi-tokens, as well as by decentralized applications, vaults, and any other on-chain product that requires structured document management. + +Historically, this proposal was authored as part of a broader security token standards suite that had not yet been merged in this repository when this proposal was added. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", and "MAY" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +Implementations MUST support querying and subscribing to updates on any relevant documentation for the security. + +A document entry is identified by a name (`bytes32`) and stores: + +- A URI (`string`) pointing to the document location. +- A content hash (`bytes32`) for integrity checks. +- A last-modified timestamp (`uint256`) set when the entry is written. + +### Interface + +```solidity +/// @title IERC1643 Document Management +interface IERC1643 { + /// @notice Reverts when `setDocument` is called with `name == bytes32(0)`. + error ERC1643InvalidName(); + + /// @notice Reverts when `removeDocument` is called for a missing document. + error ERC1643MissingDocument(); + + /// @notice Returns metadata for a document identified by `name`. + /// @return uri Document location. + /// @return documentHash Hash of the document contents. + /// @return lastModified Last update timestamp. + function getDocument(bytes32 name) external view returns (string memory uri, bytes32 documentHash, uint256 lastModified); + + /// @notice Creates or updates a document entry. + /// @dev MUST emit `DocumentUpdated` on success. + function setDocument(bytes32 name, string calldata uri, bytes32 documentHash) external; + + /// @notice Removes an existing document entry. + /// @dev MUST emit `DocumentRemoved` on success. + function removeDocument(bytes32 name) external; + + /// @notice Returns all document names currently tracked by the contract. + function getAllDocuments() external view returns (bytes32[] memory documentNames); + + /// @notice Emitted when a document is created or updated. + event DocumentUpdated(bytes32 indexed name, string uri, bytes32 documentHash); + + /// @notice Emitted when a document is removed. + event DocumentRemoved(bytes32 indexed name, string uri, bytes32 documentHash); +} +``` + +### Interface Detection ([ERC-165](./eip-165.md)) + +Implementations SHOULD support ERC-165 interface detection. + +When ERC-165 is implemented, `supportsInterface` SHOULD return `true` for `type(IERC1643).interfaceId` and for the ERC-165 interface id. + +### Function Requirements + +- `getDocument`: + - MUST return the latest values for the provided document name. + - MUST return empty values when the entry does not exist (`""`, `bytes32(0)`, `0`). + - MUST NOT revert solely because the entry does not exist. + +- `setDocument`: + - MUST create a new entry when `name` is not present. + - MUST overwrite the existing entry when `name` already exists. + - MUST update the stored last-modified timestamp. + - MUST emit `DocumentUpdated` after state changes. + - MUST revert if the update cannot be persisted. + - SHOULD revert when `name == bytes32(0)` to avoid ambiguous/default-key usage. + - `uri` and `documentHash` MAY be empty (`""` and `bytes32(0)`), depending on issuer workflow and document lifecycle stage. + - Implementations MAY decide to reject empty `uri` and/or empty `documentHash` based on policy requirements. + - Implementations SHOULD use the custom error defined in the interface (`ERC1643InvalidName()`) when rejecting `name == bytes32(0)`. + - Implementations MAY use different error names/signatures than those shown in this specification. + +- `removeDocument`: + - MUST remove the entry identified by `name`. + - MUST emit `DocumentRemoved` with the removed metadata. + - MUST revert if removal cannot be completed. + - Implementations SHOULD use the custom error defined in the interface (`ERC1643MissingDocument()`) when the named document does not exist. + - Implementations MAY use different error names/signatures than those shown in this specification. + +- `getAllDocuments`: + - MUST include every document name added by `setDocument` and not removed by `removeDocument`. + - MUST NOT include removed document names. + +## Rationale + +The standard uses `bytes32` names to keep keys compact and deterministic, while leaving naming conventions to implementations. A URI-based pointer is used instead of on-chain document storage to avoid high gas costs and to support existing off-chain document systems. + +Including a document hash enables clients to verify that fetched off-chain content matches issuer-published metadata. Emitting update and removal events supports indexing and near-real-time monitoring without repeated full-state polling. + +While a human-readable document title cannot always be represented directly in `bytes32` without hashing or canonicalization, `bytes32` remains practical for on-chain identifiers because fixed-size values can be compared directly (`a == b`). By contrast, `string` comparisons generally require hashing (for example, `keccak256(bytes(s))`), which increases contract code size and gas usage when repeated comparisons are needed on-chain, such as locating and removing a document name from an array. + +## Backwards Compatibility + +This ERC is additive and does not alter base token transfer semantics. It can be implemented alongside existing token standards and permissioning systems without changing their core behavior. + +## Test Cases + +Implementations should verify at least the following: + +- Adding a new document and reading it through `getDocument`. +- Updating an existing document and validating changed URI/hash/timestamp. +- Removing a document and ensuring it is no longer returned by `getAllDocuments`. +- Emission of `DocumentUpdated` on create/update and `DocumentRemoved` on delete. +- Enumeration consistency after multiple add/update/remove operations. + +## Reference Implementation + +The interface is provided in [the reference interface](../assets/eip-1643/src/erc-1643/IERC1643.sol). A reusable abstract module implementing the full interface is provided in [the reference module](../assets/eip-1643/src/erc-1643/ERC1643.sol). Example integrations attaching the module to [ERC-20](./eip-20.md) and [ERC-721](./eip-721.md) tokens are provided in [the ERC-20 example](../assets/eip-1643/src/ERC20DocumentToken.sol) and [the ERC-721 example](../assets/eip-1643/src/ERC721DocumentToken.sol). These examples use the OpenZeppelin library and restrict document mutation to the contract owner. They are provided for educational purposes only and have not been audited. + +The module maintains: + +- Mapping from `bytes32` name to document metadata. +- Array/set for enumeration of active names. +- Index tracking to support O(1) removals from the enumeration set. + +## Security Considerations + +- Document URIs may reference mutable off-chain content. Consumers are strongly encouraged to verify content using the published `documentHash` and trusted retrieval channels. +- Implementations should protect `setDocument` and `removeDocument` with appropriate authorization, otherwise unauthorized actors can modify legal or operational references. +- Applications should treat event streams as advisory and reconcile against on-chain state when correctness is critical. +- Document names may not always fit cleanly into `bytes32`, especially for long legal titles. Implementations should avoid lossy truncation of human-readable names; using a deterministic hash-based identifier (for example, the document content hash or a hash of a canonical full title) as the `bytes32` name is a safer alternative. +- The custom errors `ERC1643InvalidName()` and `ERC1643MissingDocument()` are defined in this interface but were absent from the original [ERC-1643](./eip-1643.md) proposal text. Older implementations may not define these errors and may instead revert with strings or implementation-specific error patterns. Integrators should not assume all ERC-1643 contracts expose identical revert data. +- ERC-165 interface detection was also not part of the earlier ERC-1643 draft text. Older implementations may not expose `supportsInterface` for ERC-165 or `IERC1643`, so integrators should treat ERC-165 support as optional when interacting with legacy deployments. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/doc/slither-report.md b/doc/slither-report.md deleted file mode 100644 index cc8c1dd..0000000 --- a/doc/slither-report.md +++ /dev/null @@ -1,38 +0,0 @@ -**THIS CHECKLIST IS NOT COMPLETE**. Use `--show-ignored-findings` to show all the results. -Summary - - [dead-code](#dead-code) (1 results) (Informational) - - [solc-version](#solc-version) (1 results) (Informational) -## dead-code - -> Acknowledge - -Impact: Informational -Confidence: Medium - - - [ ] ID-0 -[DocumentEngine._msgData()](src/DocumentEngine.sol#L265-L272) is never used and should be removed - -src/DocumentEngine.sol#L265-L272 - -## solc-version - -> Acknowledge - -Impact: Informational -Confidence: High - - [ ] ID-1 - Version constraint ^0.8.20 contains known severe issues (https://solidity.readthedocs.io/en/latest/bugs.html) - - VerbatimInvalidDeduplication - - FullInlinerNonExpressionSplitArgumentEvaluationOrder - - MissingSideEffectsOnSelectorAccess. - It is used by: - - lib/CMTAT/contracts/interfaces/engine/draft-IERC1643.sol#3 - - lib/openzeppelin-contracts/contracts/access/AccessControl.sol#4 - - lib/openzeppelin-contracts/contracts/access/IAccessControl.sol#4 - - lib/openzeppelin-contracts/contracts/metatx/ERC2771Context.sol#4 - - lib/openzeppelin-contracts/contracts/utils/Context.sol#4 - - lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol#4 - - lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol#4 - - src/DocumentEngine.sol#2 - - src/DocumentEngineInvariant.sol#2 - diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..be96ac4 --- /dev/null +++ b/foundry.lock @@ -0,0 +1,20 @@ +{ + "lib/CMTAT": { + "rev": "e8048d43b0299afd83f150d3725ab299994b4271" + }, + "lib/RuleEngine": { + "tag": { + "name": "v2.1.0", + "rev": "461d32fca6cf501d6c15f3aed5f18855b2a6581c" + } + }, + "lib/forge-std": { + "rev": "1714bee72e286e73f76e320d110e0eaf5c4e649d" + }, + "lib/openzeppelin-contracts": { + "rev": "dbb6104ce834628e473d2173bbc9d47f81a9eec3" + }, + "lib/openzeppelin-contracts-upgradeable": { + "rev": "723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1" + } +} \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index acc6e5a..8945d0e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,10 +1,10 @@ [profile.default] -solc = "0.8.26" +solc = "0.8.34" src = "src" out = "out" libs = ["lib"] optimizer = true optimizer_runs = 200 -evm_version = 'cancun' +evm_version = 'prague' # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/CMTAT b/lib/CMTAT index e8048d4..580d477 160000 --- a/lib/CMTAT +++ b/lib/CMTAT @@ -1 +1 @@ -Subproject commit e8048d43b0299afd83f150d3725ab299994b4271 +Subproject commit 580d4776e4cbb857b2da7d83fd79144ae7e47557 diff --git a/lib/RuleEngine b/lib/RuleEngine new file mode 160000 index 0000000..461d32f --- /dev/null +++ b/lib/RuleEngine @@ -0,0 +1 @@ +Subproject commit 461d32fca6cf501d6c15f3aed5f18855b2a6581c diff --git a/lib/forge-std b/lib/forge-std index 1714bee..f73c73d 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit f73c73d2018eb6a111f35e4dae7b4f27401e9421 diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index dbb6104..5fd1781 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 +Subproject commit 5fd1781b1454fd1ef8e722282f86f9293cacf256 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable index 723f8ca..7bf4727 160000 --- a/lib/openzeppelin-contracts-upgradeable +++ b/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 +Subproject commit 7bf4727aacdbfaa0f36cbd664654d0c9e1dc52bf diff --git a/remappings.txt b/remappings.txt index b0803f5..31bf2f2 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1,3 +1,4 @@ CMTAT/=lib/CMTAT/contracts/ +RuleEngine/=lib/RuleEngine/src/ OZ/=lib/openzeppelin-contracts/contracts/ @openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ \ No newline at end of file diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 3f4fff9..2d18acd 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.20; import "OZ/access/AccessControl.sol"; import "OZ/metatx/ERC2771Context.sol"; -import "CMTAT/interfaces/engine/draft-IERC1643.sol"; +import "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import "./DocumentEngineInvariant.sol"; /** @@ -20,7 +20,7 @@ contract DocumentEngine is * @notice * Get the current version of the smart contract */ - string public constant VERSION = "0.3.0"; + string public constant VERSION = "0.4.0"; // Mapping from contract addresses to document names to their corresponding Document structs mapping(address => mapping(bytes32 => Document)) private _documents; mapping(address => bytes32[]) private _documentNames; @@ -62,6 +62,33 @@ contract DocumentEngine is _removeDocument(smartContract, name_); } + /* ============ ERC-1643 (bound token) ============ */ + + /** + * @notice ERC-1643 function to set or update a document for the caller. + * @dev The document is stored under the caller (`_msgSender()`) namespace. + * The caller must be a token bound to this engine (`TOKEN_CONTRACT_ROLE`), + * following the RuleEngine binding pattern. A bound token can only manage + * its own documents; it can never affect another contract's documents. + */ + function setDocument( + bytes32 name_, + string calldata uri_, + bytes32 documentHash_ + ) external override onlyRole(TOKEN_CONTRACT_ROLE) { + _setDocument(_msgSender(), name_, uri_, documentHash_); + } + + /** + * @notice ERC-1643 function to remove a document for the caller. + * @dev See {setDocument}. Scoped to the caller (`_msgSender()`) namespace. + */ + function removeDocument( + bytes32 name_ + ) external override onlyRole(TOKEN_CONTRACT_ROLE) { + _removeDocument(_msgSender(), name_); + } + /** * @notice Batch version of setDocument to handle multiple documents at once */ @@ -141,12 +168,12 @@ contract DocumentEngine is } /** - * @notice Public function to get a document from msg.sender + * @notice ERC-1643 function to get a document for the caller (`_msgSender()`) */ function getDocument( bytes32 name_ - ) external view override returns (string memory, bytes32, uint256) { - return _getDocument(msg.sender, name_); + ) external view override returns (Document memory) { + return _getDocument(_msgSender(), name_); } /** @@ -155,7 +182,7 @@ contract DocumentEngine is function getDocument( address smartContract, bytes32 name_ - ) external view returns (string memory, bytes32, uint256) { + ) external view returns (Document memory) { return _getDocument(smartContract, name_); } @@ -168,7 +195,7 @@ contract DocumentEngine is override returns (bytes32[] memory) { - return _documentNames[msg.sender]; + return _documentNames[_msgSender()]; } /** @@ -205,9 +232,8 @@ contract DocumentEngine is function _getDocument( address smartContract, bytes32 name_ - ) internal view returns (string memory, bytes32, uint256) { - Document memory doc = _documents[smartContract][name_]; - return (doc.uri, doc.documentHash, doc.lastModified); + ) internal view returns (Document memory) { + return _documents[smartContract][name_]; } /** @@ -231,7 +257,15 @@ contract DocumentEngine is function _removeDocument(address smartContract, bytes32 name_) internal { Document memory doc = _documents[smartContract][name_]; - emit DocumentRemoved(smartContract, name_, doc.uri, doc.documentHash); + // Standard ERC-1643 event + emit DocumentRemoved(name_, doc.uri, doc.documentHash); + // Optional multi-token event (see ERC-1643-proposition.md) + emit DocumentRemovedForContract( + smartContract, + name_, + doc.uri, + doc.documentHash + ); delete _documents[smartContract][name_]; _removeDocumentName(smartContract, name_); @@ -251,7 +285,15 @@ contract DocumentEngine is doc.uri = uri_; doc.documentHash = documentHash_; doc.lastModified = block.timestamp; - emit DocumentUpdated(smartContract, name_, uri_, documentHash_); + // Standard ERC-1643 event + emit DocumentUpdated(name_, uri_, documentHash_); + // Optional multi-token event (see ERC-1643-proposition.md) + emit DocumentUpdatedForContract( + smartContract, + name_, + uri_, + documentHash_ + ); } /*////////////////////////////////////////////////////////////// diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index b3836d8..2d229fd 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -6,26 +6,35 @@ contract DocumentEngineInvariant { error InvalidInputLength(); error AdminWithAddressZeroNotAllowed(); - event DocumentUpdated( - address smartContract, - bytes32 name, + /** + * @notice Optional multi-token events emitted in addition to the standard + * `IERC1643.DocumentUpdated` / `IERC1643.DocumentRemoved` events. + * @dev Because this engine manages documents on behalf of several smart + * contracts (tokens), the standard events - which only carry the document + * `name` - are not sufficient to identify which contract a document belongs + * to. These events add the `smartContract` address for off-chain indexers. + * See `ERC-1643-proposition.md` for the proposed optional standard extension. + */ + event DocumentUpdatedForContract( + address indexed smartContract, + bytes32 indexed name, string uri, bytes32 documentHash ); - event DocumentRemoved( - address smartContract, - bytes32 name, + event DocumentRemovedForContract( + address indexed smartContract, + bytes32 indexed name, string uri, bytes32 documentHash ); - // Document structure - struct Document { - string uri; - bytes32 documentHash; - uint256 lastModified; - } - + // Role allowed to manage documents on behalf of any smart contract (admin path) bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); + + // Role granted to a token bound to the engine, allowing it to manage its own + // documents through the standard ERC-1643 functions (msg.sender is the token). + // Mirrors the RuleEngine binding pattern (CMTA/RuleEngine `TOKEN_CONTRACT_ROLE`). + bytes32 public constant TOKEN_CONTRACT_ROLE = + keccak256("TOKEN_CONTRACT_ROLE"); } diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index fda9890..abed6b5 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -5,7 +5,22 @@ import "forge-std/Test.sol"; import "../src/DocumentEngine.sol"; import "../src/DocumentEngineInvariant.sol"; import "OZ/access/AccessControl.sol"; -import "CMTAT/CMTAT_STANDALONE.sol"; +import {DocumentEngineModule} from "CMTAT/modules/wrapper/options/DocumentEngineModule.sol"; + +/** + * @dev Minimal token wired to CMTAT's official `DocumentEngineModule`. + * + * Since CMTAT v3, the shipped standalone tokens store documents on-chain + * (`DocumentERC1643Module`) and no longer consume an external document engine + * through their constructor. Integration with an external `DocumentEngine` now + * goes through `DocumentEngineModule`, which this mock exercises with real + * CMTAT code: reads/writes are forwarded to the engine keyed by `msg.sender`. + */ +contract CMTATDocumentEngineMock is DocumentEngineModule { + // No access restriction for the mock: document management is authorized for anyone. + function _authorizeDocumentManagement() internal override {} +} + contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { DocumentEngine public documentEngine; address public admin = address(0x1); @@ -18,7 +33,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { bytes32 public documentHash = keccak256("doc1Hash"); bytes32 public constant DOCUMENT_ROLE = keccak256("DOCUMENT_ROLE"); address AddressZero = address(0); - CMTAT_STANDALONE cmtat; + function setUp() public { documentEngine = new DocumentEngine(admin, AddressZero); vm.prank(admin); @@ -28,34 +43,6 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentURI, documentHash ); - - // CMTAT - ICMTATConstructor.ERC20Attributes - memory erc20Attributes = ICMTATConstructor.ERC20Attributes( - "CMTA Token", - "CMTAT", - 0 - ); - ICMTATConstructor.BaseModuleAttributes - memory baseModuleAttributes = ICMTATConstructor - .BaseModuleAttributes( - "CMTAT_ISIN", - "https://cmta.ch", - "CMTAT_info" - ); - ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine( - IRuleEngine(AddressZero), - IDebtEngine(AddressZero), - IAuthorizationEngine(AddressZero), - IERC1643(AddressZero) - ); - cmtat = new CMTAT_STANDALONE( - AddressZero, - admin, - erc20Attributes, - baseModuleAttributes, - engines - ); } /*////////////////////////////////////////////////////////////// @@ -177,17 +164,24 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { } /*////////////////////////////////////////////////////////////// - Get + Get //////////////////////////////////////////////////////////////*/ - function testGetAllDocuments() public view { + function testGetAllDocuments() public { bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 1); assertEq(docs[0], documentName); } + /*////////////////////////////////////////////////////////////// + CMTAT integration (external engine via DocumentEngineModule) + //////////////////////////////////////////////////////////////*/ + function testCanReturnCMTATDocument() public { - // Arrange + // Arrange: a CMTAT-style token bound to the engine + CMTATDocumentEngineMock cmtat = new CMTATDocumentEngineMock(); + cmtat.setDocumentEngine(documentEngine); + uint256 lastModif = block.timestamp; vm.prank(admin); documentEngine.setDocument( @@ -196,19 +190,76 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentURI, documentHash ); - vm.prank(admin); - cmtat.setDocumentEngine(documentEngine); - // Call from CMTAT, return document + // Call from CMTAT, forwarded to the engine bytes32[] memory docs = cmtat.getAllDocuments(); assertEq(docs.length, 1); assertEq(docs[0], documentName); - (string memory uri, bytes32 hash, uint256 lastModified) = cmtat - .getDocument(documentName); - assertEq(uri, documentURI); - assertEq(hash, documentHash); - assertEq(lastModif, lastModified); + IERC1643.Document memory doc = cmtat.getDocument(documentName); + assertEq(doc.uri, documentURI); + assertEq(doc.documentHash, documentHash); + assertEq(doc.lastModified, lastModif); + } + + /*////////////////////////////////////////////////////////////// + Bound token (RuleEngine binding pattern, TOKEN_CONTRACT_ROLE) + //////////////////////////////////////////////////////////////*/ + + function testBoundTokenCanManageOwnDocument() public { + // Bind the token to the engine + vm.prank(admin); + documentEngine.grantRole(TOKEN_CONTRACT_ROLE, testContract); + + // The bound token manages its own document namespace (msg.sender) + bytes32 selfName = keccak256("self-doc"); + string memory selfURI = "https://example.com/self"; + bytes32 selfHash = keccak256("selfHash"); + + vm.prank(testContract); + documentEngine.setDocument(selfName, selfURI, selfHash); + + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + selfName + ); + assertEq(doc.uri, selfURI); + assertEq(doc.documentHash, selfHash); + assertEq(doc.lastModified, block.timestamp); + + // and can remove it + vm.prank(testContract); + documentEngine.removeDocument(selfName); + doc = documentEngine.getDocument(testContract, selfName); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); + } + + function testUnboundContractCannotSetOwnDocument() public { + bytes32 selfName = keccak256("self-doc"); + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + AccessControlUnauthorizedAccount.selector, + attacker, + TOKEN_CONTRACT_ROLE + ) + ); + documentEngine.setDocument(selfName, documentURI, documentHash); + } + + function testUnboundContractCannotRemoveOwnDocument() public { + bytes32 selfName = keccak256("self-doc"); + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + AccessControlUnauthorizedAccount.selector, + attacker, + TOKEN_CONTRACT_ROLE + ) + ); + documentEngine.removeDocument(selfName); } /*////////////////////////////////////////////////////////////// @@ -224,11 +275,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentHash ); - (string memory uri, bytes32 hash, uint256 lastModified) = documentEngine - .getDocument(testContract, documentName); - assertEq(uri, documentURI); - assertEq(hash, documentHash); - assertEq(lastModif, lastModified); + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, documentURI); + assertEq(doc.documentHash, documentHash); + assertEq(doc.lastModified, lastModif); } function testAdminCanSetDocumentAgain() public { @@ -256,11 +309,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { ); // Assert - (string memory uri, bytes32 hash, uint256 lastModified) = documentEngine - .getDocument(testContract, documentName); - assertEq(uri, documentURIV2); - assertEq(hash, documentHashV2); - assertEq(lastModif, lastModified); + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, documentURIV2); + assertEq(doc.documentHash, documentHashV2); + assertEq(doc.lastModified, lastModif); docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 1); assertEq(docs[0], documentName); @@ -287,24 +342,22 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(smartContracts, names, uris, hashes); // Check the first document - ( - string memory uri1, - bytes32 hash1, - uint256 lastModified1 - ) = documentEngine.getDocument(testContract, documentName); - assertEq(uri1, documentURI); - assertEq(hash1, documentHash); - assertEq(lastModified1, block.timestamp); + IERC1643.Document memory doc1 = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc1.uri, documentURI); + assertEq(doc1.documentHash, documentHash); + assertEq(doc1.lastModified, block.timestamp); // Check the second document - ( - string memory uri2, - bytes32 hash2, - uint256 lastModified2 - ) = documentEngine.getDocument(anotherSmartContract, names[1]); - assertEq(uri2, uris[1]); - assertEq(hash2, hashes[1]); - assertEq(lastModified2, block.timestamp); + IERC1643.Document memory doc2 = documentEngine.getDocument( + anotherSmartContract, + names[1] + ); + assertEq(doc2.uri, uris[1]); + assertEq(doc2.documentHash, hashes[1]); + assertEq(doc2.lastModified, block.timestamp); } function testAdminCanBatchSetDocumentsForTheSameContract() public { @@ -328,24 +381,22 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(smartContracts, names, uris, hashes); // Check the first document - ( - string memory uri1, - bytes32 hash1, - uint256 lastModified1 - ) = documentEngine.getDocument(testContract, documentName); - assertEq(uri1, documentURI); - assertEq(hash1, documentHash); - assertEq(lastModified1, block.timestamp); + IERC1643.Document memory doc1 = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc1.uri, documentURI); + assertEq(doc1.documentHash, documentHash); + assertEq(doc1.lastModified, block.timestamp); // Check the second document - ( - string memory uri2, - bytes32 hash2, - uint256 lastModified2 - ) = documentEngine.getDocument(testContract, names[1]); - assertEq(uri2, uris[1]); - assertEq(hash2, hashes[1]); - assertEq(lastModified2, block.timestamp); + IERC1643.Document memory doc2 = documentEngine.getDocument( + testContract, + names[1] + ); + assertEq(doc2.uri, uris[1]); + assertEq(doc2.documentHash, hashes[1]); + assertEq(doc2.lastModified, block.timestamp); } function testCannotAddBatchDocumentIfLengthMismatch_A() public { @@ -412,11 +463,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - (string memory uri, bytes32 hash, uint256 lastModified) = documentEngine - .getDocument(testContract, documentName); - assertEq(uri, ""); - assertEq(hash, ""); - assertEq(lastModified, 0); + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 0); } @@ -439,22 +492,23 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - (string memory uri, bytes32 hash, uint256 lastModified) = documentEngine - .getDocument(testContract, documentName); - assertEq(uri, ""); - assertEq(hash, ""); - assertEq(lastModified, 0); + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 0); - ( - string memory uri2, - bytes32 hash2, - uint256 lastModified2 - ) = documentEngine.getDocument(anotherSmartContract, names[1]); - assertEq(uri2, ""); - assertEq(hash2, ""); - assertEq(lastModified2, 0); + IERC1643.Document memory doc2 = documentEngine.getDocument( + anotherSmartContract, + names[1] + ); + assertEq(doc2.uri, ""); + assertEq(doc2.documentHash, ""); + assertEq(doc2.lastModified, 0); docs = documentEngine.getAllDocuments(anotherSmartContract); assertEq(docs.length, 0); } @@ -500,24 +554,22 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(testContract, names, uris, hashes); // Check the first document - ( - string memory uri1, - bytes32 hash1, - uint256 lastModified1 - ) = documentEngine.getDocument(testContract, documentName); - assertEq(uri1, documentURI); - assertEq(hash1, documentHash); - assertEq(lastModified1, block.timestamp); + IERC1643.Document memory doc1 = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc1.uri, documentURI); + assertEq(doc1.documentHash, documentHash); + assertEq(doc1.lastModified, block.timestamp); // Check the second document - ( - string memory uri2, - bytes32 hash2, - uint256 lastModified2 - ) = documentEngine.getDocument(testContract, names[1]); - assertEq(uri2, uris[1]); - assertEq(hash2, hashes[1]); - assertEq(lastModified2, block.timestamp); + IERC1643.Document memory doc2 = documentEngine.getDocument( + testContract, + names[1] + ); + assertEq(doc2.uri, uris[1]); + assertEq(doc2.documentHash, hashes[1]); + assertEq(doc2.lastModified, block.timestamp); } function testAdminCanBatchRemoveDocumentsForOnlyOneContract() public { @@ -534,21 +586,22 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - (string memory uri, bytes32 hash, uint256 lastModified) = documentEngine - .getDocument(testContract, documentName); - assertEq(uri, ""); - assertEq(hash, ""); - assertEq(lastModified, 0); + IERC1643.Document memory doc = documentEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 0); - ( - string memory uri2, - bytes32 hash2, - uint256 lastModified2 - ) = documentEngine.getDocument(testContract, names[1]); - assertEq(uri2, ""); - assertEq(hash2, ""); - assertEq(lastModified2, 0); + IERC1643.Document memory doc2 = documentEngine.getDocument( + testContract, + names[1] + ); + assertEq(doc2.uri, ""); + assertEq(doc2.documentHash, ""); + assertEq(doc2.lastModified, 0); } function testCannotRemoveBatchDocumentIfEmptyLengthForOnlyOneContract() From afb03c2ad606fdaca4b91e1c8bc9fcde50b265fd Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:17:17 +0200 Subject: [PATCH 02/19] refactor: delegate access control to overridable _authorize hooks (CMTAT/RuleEngine pattern) --- AGENTS.md | 7 +++++ CHANGELOG.md | 1 + CLAUDE.md | 7 +++++ README.md | 38 ++++++++++++++++++----- src/DocumentEngine.sol | 64 +++++++++++++++++++++++++++++++++------ test/DocumentEngine.t.sol | 44 +++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca68820..ca26a28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,13 @@ addressed by a `bytes32` name. - **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. - **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the `hasRole` override). +- **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions + use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to + overridable `internal virtual` hooks `_authorizeDocumentManagement()` / + `_authorizeBoundTokenDocumentManagement()` (default `DOCUMENT_MANAGER_ROLE` / + `TOKEN_CONTRACT_ROLE`). Keep the management implementation separate from the + authorization logic — change *who* is authorized by overriding a hook, not by + editing the management functions. - **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded keyed by the token address). Standard CMTAT standalone tokens store documents diff --git a/CHANGELOG.md b/CHANGELOG.md index 576d56c..0ee30c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. +- **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index ca68820..ca26a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,13 @@ addressed by a `bytes32` name. - **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. - **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the `hasRole` override). +- **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions + use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to + overridable `internal virtual` hooks `_authorizeDocumentManagement()` / + `_authorizeBoundTokenDocumentManagement()` (default `DOCUMENT_MANAGER_ROLE` / + `TOKEN_CONTRACT_ROLE`). Keep the management implementation separate from the + authorization logic — change *who* is authorized by overriding a hook, not by + editing the management functions. - **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded keyed by the token address). Standard CMTAT standalone tokens store documents diff --git a/README.md b/README.md index e6e8d21..9b8e488 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,28 @@ function setDocument(bytes32 name_, string calldata uri_, bytes32 documentHash_) function removeDocument(bytes32 name_) external; ``` +### Flexible access control + +Following the CMTAT / [RuleEngine](https://github.com/CMTA/RuleEngine) pattern, +the restricted functions do not hardcode a role check. They carry a **modifier** +(`onlyDocumentManager` / `onlyBoundToken`) that delegates to an **overridable +`internal virtual` authorization hook**: + +```solidity +function _authorizeDocumentManagement() internal view virtual { + _checkRole(DOCUMENT_MANAGER_ROLE); +} + +function _authorizeBoundTokenDocumentManagement() internal view virtual { + _checkRole(TOKEN_CONTRACT_ROLE); +} +``` + +This separates the document-management implementation from the authorization +logic: a subclass can override a hook to change *who* is authorized (e.g. a +different role, an allowlist, or open access) without touching the management +functions. The default behavior is the role checks shown above. + ### Events On every write, the engine emits the standard `IERC1643` events **and** the @@ -112,14 +134,14 @@ reads/writes are then forwarded to the engine keyed by the token address. | | | | | | | **DocumentEngine** | Implementation | IERC1643, DocumentEngineInvariant, AccessControl, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | -| └ | setDocument | Public ❗️ | 🛑 | onlyRole (DOCUMENT_MANAGER_ROLE) | -| └ | removeDocument | External ❗️ | 🛑 | onlyRole (DOCUMENT_MANAGER_ROLE) | -| └ | setDocument | External ❗️ | 🛑 | onlyRole (TOKEN_CONTRACT_ROLE) | -| └ | removeDocument | External ❗️ | 🛑 | onlyRole (TOKEN_CONTRACT_ROLE) | -| └ | batchSetDocuments | External ❗️ | 🛑 | onlyRole | -| └ | batchSetDocuments | External ❗️ | 🛑 | onlyRole | -| └ | batchRemoveDocuments | External ❗️ | 🛑 | onlyRole | -| └ | batchRemoveDocuments | External ❗️ | 🛑 | onlyRole | +| └ | setDocument | Public ❗️ | 🛑 | onlyDocumentManager | +| └ | removeDocument | External ❗️ | 🛑 | onlyDocumentManager | +| └ | setDocument | External ❗️ | 🛑 | onlyBoundToken | +| └ | removeDocument | External ❗️ | 🛑 | onlyBoundToken | +| └ | batchSetDocuments | External ❗️ | 🛑 | onlyDocumentManager | +| └ | batchSetDocuments | External ❗️ | 🛑 | onlyDocumentManager | +| └ | batchRemoveDocuments | External ❗️ | 🛑 | onlyDocumentManager | +| └ | batchRemoveDocuments | External ❗️ | 🛑 | onlyDocumentManager | | └ | getDocument | External ❗️ | | NO❗️ | | └ | getDocument | External ❗️ | | NO❗️ | | └ | getAllDocuments | External ❗️ | | NO❗️ | diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 2d18acd..5f37da7 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -48,7 +48,7 @@ contract DocumentEngine is bytes32 name_, string memory uri_, bytes32 documentHash_ - ) public onlyRole(DOCUMENT_MANAGER_ROLE) { + ) public onlyDocumentManager { _setDocument(smartContract, name_, uri_, documentHash_); } @@ -58,7 +58,7 @@ contract DocumentEngine is function removeDocument( address smartContract, bytes32 name_ - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { + ) external onlyDocumentManager { _removeDocument(smartContract, name_); } @@ -75,7 +75,7 @@ contract DocumentEngine is bytes32 name_, string calldata uri_, bytes32 documentHash_ - ) external override onlyRole(TOKEN_CONTRACT_ROLE) { + ) external override onlyBoundToken { _setDocument(_msgSender(), name_, uri_, documentHash_); } @@ -85,7 +85,7 @@ contract DocumentEngine is */ function removeDocument( bytes32 name_ - ) external override onlyRole(TOKEN_CONTRACT_ROLE) { + ) external override onlyBoundToken { _removeDocument(_msgSender(), name_); } @@ -97,7 +97,7 @@ contract DocumentEngine is bytes32[] calldata names, string[] calldata uris, bytes32[] calldata hashes - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { + ) external onlyDocumentManager { if ( smartContracts.length == 0 || smartContracts.length != names.length || @@ -119,7 +119,7 @@ contract DocumentEngine is bytes32[] calldata names, string[] calldata uris, bytes32[] calldata hashes - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { + ) external onlyDocumentManager { if ( names.length == 0 || names.length != uris.length || @@ -138,7 +138,7 @@ contract DocumentEngine is function batchRemoveDocuments( address[] calldata smartContracts, bytes32[] calldata names - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { + ) external onlyDocumentManager { if ( smartContracts.length == 0 || (smartContracts.length != names.length) @@ -157,7 +157,7 @@ contract DocumentEngine is function batchRemoveDocuments( address smartContract, bytes32[] calldata names - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { + ) external onlyDocumentManager { if (names.length == 0) { revert InvalidInputLength(); } @@ -207,7 +207,53 @@ contract DocumentEngine is return _documentNames[smartContract]; } - /* ============ ACCESS CONTROL ============ */ + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL (flexible) + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Restricts a function to accounts allowed to manage documents on + * behalf of any smart contract (admin path). + * + * The access-control logic is delegated to {_authorizeDocumentManagement}, + * so the document-management implementation is kept separate from the + * authorization: a subclass can override the hook to change *who* is + * authorized without touching the management functions + * (pattern borrowed from CMTAT and CMTA/RuleEngine). + */ + modifier onlyDocumentManager() { + _authorizeDocumentManagement(); + _; + } + + /** + * @dev Restricts a function to tokens bound to this engine, letting them + * manage their own documents (bound-token path). Delegates to + * {_authorizeBoundTokenDocumentManagement}. + */ + modifier onlyBoundToken() { + _authorizeBoundTokenDocumentManagement(); + _; + } + + /** + * @dev Authorization hook for the admin document-management path. + * Default: the caller must hold `DOCUMENT_MANAGER_ROLE`. + * Override to customize the authorization logic. + */ + function _authorizeDocumentManagement() internal view virtual { + _checkRole(DOCUMENT_MANAGER_ROLE); + } + + /** + * @dev Authorization hook for the bound-token document-management path. + * Default: the caller must hold `TOKEN_CONTRACT_ROLE` (the RuleEngine + * binding pattern). Override to customize the authorization logic. + */ + function _authorizeBoundTokenDocumentManagement() internal view virtual { + _checkRole(TOKEN_CONTRACT_ROLE); + } + /* * @dev Returns `true` if `account` has been granted `role`. */ diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index abed6b5..7104913 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -21,6 +21,22 @@ contract CMTATDocumentEngineMock is DocumentEngineModule { function _authorizeDocumentManagement() internal override {} } +/** + * @dev Demonstrates the flexible access control: overriding the authorization + * hook opens the admin document-management path to anyone, without touching the + * document-management implementation. + */ +contract OpenDocumentEngine is DocumentEngine { + constructor( + address admin, + address forwarder + ) DocumentEngine(admin, forwarder) {} + + function _authorizeDocumentManagement() internal view override { + // no access restriction (custom authorization) + } +} + contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { DocumentEngine public documentEngine; address public admin = address(0x1); @@ -262,6 +278,34 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.removeDocument(selfName); } + /*////////////////////////////////////////////////////////////// + Flexible access control (overridable authorization hook) + //////////////////////////////////////////////////////////////*/ + + function testFlexibleAuthorizationCanBeOverridden() public { + OpenDocumentEngine openEngine = new OpenDocumentEngine( + admin, + AddressZero + ); + + // attacker holds no role, yet can manage documents because the + // authorization hook was overridden to allow anyone. + vm.prank(attacker); + openEngine.setDocument( + testContract, + documentName, + documentURI, + documentHash + ); + + IERC1643.Document memory doc = openEngine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, documentURI); + assertEq(doc.documentHash, documentHash); + } + /*////////////////////////////////////////////////////////////// Set documents //////////////////////////////////////////////////////////////*/ From 19cdb8ff3934c680454df2e22b073947179245b7 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:20:34 +0200 Subject: [PATCH 03/19] refactor: split DocumentEngine into DocumentEngineBase (logic) + DocumentEngine (access control + deployment) --- AGENTS.md | 16 +- CHANGELOG.md | 1 + CLAUDE.md | 16 +- README.md | 18 +- doc/ERCSpecification/erc-8303-draft.md | 131 +++++++++++ src/DocumentEngine.sol | 308 ++---------------------- src/DocumentEngineBase.sol | 311 +++++++++++++++++++++++++ 7 files changed, 505 insertions(+), 296 deletions(-) create mode 100644 doc/ERCSpecification/erc-8303-draft.md create mode 100644 src/DocumentEngineBase.sol diff --git a/AGENTS.md b/AGENTS.md index ca26a28..1791154 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,17 +49,27 @@ addressed by a `bytes32` name. ``` src/ -├── DocumentEngine.sol # Main contract: ERC-1643 impl, both management -│ # paths, batch functions, ERC-2771, access control +├── DocumentEngineBase.sol # Abstract base: ERC-1643 document logic + storage, +│ # both management paths, batch functions, modifiers, +│ # and the ABSTRACT _authorize* hooks (no access control) +├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL +│ # (AccessControl, _authorize* impls, hasRole) + ERC-2771, +│ # VERSION, constructor └── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, # TOKEN_CONTRACT_ROLE) and the optional multi-token events test/ └── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, # bound-token path, batch ops, CMTAT integration - # (CMTATDocumentEngineMock built on DocumentEngineModule) + # (CMTATDocumentEngineMock built on DocumentEngineModule), + # flexible-authorization override (OpenDocumentEngine) ``` +**Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds +the document logic and abstract `_authorize*` hooks; `DocumentEngine` is the +deployable contract that supplies the concrete access control. Add new +management logic in the base; change *who* is authorized in the deployment. + Other important files: - `foundry.toml` — solc `0.8.34`, `evm_version = prague` (required by CMTAT v3). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee30c9..f1b5a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. - **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. +- **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index ca26a28..1791154 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,17 +49,27 @@ addressed by a `bytes32` name. ``` src/ -├── DocumentEngine.sol # Main contract: ERC-1643 impl, both management -│ # paths, batch functions, ERC-2771, access control +├── DocumentEngineBase.sol # Abstract base: ERC-1643 document logic + storage, +│ # both management paths, batch functions, modifiers, +│ # and the ABSTRACT _authorize* hooks (no access control) +├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL +│ # (AccessControl, _authorize* impls, hasRole) + ERC-2771, +│ # VERSION, constructor └── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, # TOKEN_CONTRACT_ROLE) and the optional multi-token events test/ └── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, # bound-token path, batch ops, CMTAT integration - # (CMTATDocumentEngineMock built on DocumentEngineModule) + # (CMTATDocumentEngineMock built on DocumentEngineModule), + # flexible-authorization override (OpenDocumentEngine) ``` +**Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds +the document logic and abstract `_authorize*` hooks; `DocumentEngine` is the +deployable contract that supplies the concrete access control. Add new +management logic in the base; change *who* is authorized in the deployment. + Other important files: - `foundry.toml` — solc `0.8.34`, `evm_version = prague` (required by CMTAT v3). diff --git a/README.md b/README.md index 9b8e488..28b9272 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,22 @@ reads/writes are then forwarded to the engine keyed by the token address. +## Architecture + +The engine is split into two contracts (CMTAT module/deployment pattern): + +- **`DocumentEngineBase`** (abstract) — holds the document storage and all the + ERC-1643 document-management functions, plus the `onlyDocumentManager` / + `onlyBoundToken` modifiers and the **abstract** `_authorize*` hooks. It is + agnostic to the access-control implementation. +- **`DocumentEngine`** (deployment) — the concrete, deployable contract. It + defines the **access control** (`AccessControl`, the `_authorize*` hook + implementations and the `hasRole` override) and wires the ERC-2771 (gasless) + support. + +`DocumentEngineInvariant` provides the shared errors, roles +(`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) and the optional multi-token events. + ## Schema ### Inheritance @@ -132,7 +148,7 @@ reads/writes are then forwarded to the engine keyed by the token address. | :----------------: | :------------------: | :----------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DocumentEngine** | Implementation | IERC1643, DocumentEngineInvariant, AccessControl, ERC2771Context | | | +| **DocumentEngine** | Implementation | DocumentEngineBase, AccessControl, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | | └ | setDocument | Public ❗️ | 🛑 | onlyDocumentManager | | └ | removeDocument | External ❗️ | 🛑 | onlyDocumentManager | diff --git a/doc/ERCSpecification/erc-8303-draft.md b/doc/ERCSpecification/erc-8303-draft.md new file mode 100644 index 0000000..15be396 --- /dev/null +++ b/doc/ERCSpecification/erc-8303-draft.md @@ -0,0 +1,131 @@ +--- +eip: 8303 +title: Contract Version +description: Interface for exposing a contract implementation version string +author: Ryan Sauge (@rya-sge) +discussions-to: https://ethereum-magicians.org/t/erc-8303-contract-version/28795 +status: Draft +type: Standards Track +category: ERC +created: 2026-02-12 +--- + +## Abstract + +This ERC defines a minimal interface to expose a contract version string through a standardized `version()` view function. The design is based on the version pattern used by [ERC-3643](./eip-3643.md), while remaining token-agnostic and applicable to other smart contract domains, including DeFi applications such as lending protocols. + +## Motivation + +Integrators frequently need a simple, on-chain way to identify which contract implementation they interact with. A standardized version function improves: + +- integration safety (feature gating by version), +- operations (faster incident triage), +- governance and migration tracking (upgrade visibility), +- ecosystem tooling interoperability. + +It is also useful for end-users, developers, and security auditors to identify which version of a codebase is currently used by a deployed contract. + +The same requirement appears in permissioned token systems ([ERC-3643](./eip-3643.md)) and in DeFi systems where contracts evolve over time. + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174). + +### Interface + +```solidity +interface IERC8303 { + /// @notice Returns the implementation version string. + /// @return The version value (for example "1.0.0"). + function version() external view returns (string memory); +} +``` + +### Required Behavior + +1. **Version read** + - `version()` MUST be a view function. + - `version()` MUST NOT revert under normal operation. + - `version()` MUST return a non-empty string. + +2. **Version meaning** + - Returned values SHOULD be stable and machine-comparable by off-chain tooling. + - Returned values SHOULD follow a Semantic Versioning 2.0.0-like format: `MAJOR.MINOR.PATCH` using decimal integers (for example `1.0.0`, `3.2.1`). + - The canonical recommended pattern is `^[0-9]+\.[0-9]+\.[0-9]+$`. + - Implementations MAY define their own versioning policy, but SHOULD document it publicly. + +3. **Deployment model compatibility** + - This interface is compatible with immutable deployments and proxy-based upgradeable deployments. + - In upgradeable systems, `version()` SHOULD reflect the active implementation seen by users and integrators. + +### [ERC-165](./eip-165.md) + +Implementations SHOULD support [ERC-165](./eip-165.md) interface discovery for this interface. + +If an implementation supports [ERC-165](./eip-165.md), `supportsInterface(type(IERC8303).interfaceId)` MUST return `true`. + +- The interface id for `IERC8303` is `0x54fd4d50`. + +### Compatibility Note for ERC-3643 Integrations + +Integrators MAY treat legacy ERC-3643 token contracts exposing a compatible `version()` function as implementing this ERC even if they do not advertise ERC-165 support. + +## Rationale + +- **Minimal scope**: A single function maximizes adoption and keeps gas/runtime complexity negligible. +- **ERC-3643 alignment**: Reuses a proven pattern already used in regulated token implementations. +- **Token-agnostic design**: The interface applies to token contracts and non-token contracts alike. +- **Optional ERC-165**: ERC-165 support is recommended but not required, lowering the adoption barrier for contracts that do not implement interface discovery. When ERC-165 is supported, advertising this interface is mandatory to ensure consistent detection by integrators. +- **`string` over `bytes32`**: A human-readable string is preferred to a fixed-size bytes32 for legibility in explorers and tooling, at the cost of marginally higher gas for the return value. + +## Backwards Compatibility + +This ERC is fully additive. Contracts already exposing `version()` are naturally compatible if they match the interface signature. + +## Test Cases + +The following test cases apply to any conforming implementation. + +1. `version()` MUST NOT revert. +2. `version()` MUST return a non-empty string. +3. `version()` MUST return the version string declared by the implementation (e.g. `"1.0.0"`). +4. If the contract supports [ERC-165](./eip-165.md), `supportsInterface(0x54fd4d50)` MUST return `true`. +5. If the contract supports [ERC-165](./eip-165.md), `supportsInterface(0xffffffff)` MUST return `false`. + +## Reference Implementation + +Reference implementations are provided in the assets folder: the [interface](../assets/erc-8303/src/IERC8303.sol) and a [base implementation](../assets/erc-8303/src/ERC8303.sol), along with usage examples for [ERC-20](../assets/erc-8303/src/examples/ERC20VersionedExample.sol) and [ERC-721](../assets/erc-8303/src/examples/ERC721VersionedExample.sol) tokens. These examples are provided for educational purposes only and are not audited. + +```solidity +// SPDX-License-Identifier: CC0-1.0 +pragma solidity ^0.8.0; + +import "./IERC8303.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + +contract ERC8303Example is IERC8303, ERC165 { + function version() external pure override returns (string memory) { + return "1.0.0"; + } + + function supportsInterface(bytes4 interfaceId) + public + view + override + returns (bool) + { + return interfaceId == type(IERC8303).interfaceId + || super.supportsInterface(interfaceId); + } +} +``` + +## Security Considerations + +- `version()` is metadata and must not be used as a sole authorization primitive. +- In upgradeable systems, governance controls remain the trust anchor; version reporting does not prevent malicious upgrades. +- Integrators should combine version checks with other trust signals (governance model, audits, deployment provenance). + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 5f37da7..06bab95 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -3,27 +3,23 @@ pragma solidity ^0.8.20; import "OZ/access/AccessControl.sol"; import "OZ/metatx/ERC2771Context.sol"; -import "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; -import "./DocumentEngineInvariant.sol"; +import "./DocumentEngineBase.sol"; /** * @title DocumentEngine - * @notice contract to manage documents on-chain through ERC-1643 + * @notice Deployment contract to manage documents on-chain through ERC-1643. + * @dev Wires the document-management logic ({DocumentEngineBase}) with a + * concrete access-control implementation. The authorization hooks are defined + * here (role-based `AccessControl`), keeping the access control separate from + * the document-management logic (CMTAT / CMTA-RuleEngine pattern). It also wires + * the ERC-2771 (gasless) meta-transaction support. */ -contract DocumentEngine is - IERC1643, - DocumentEngineInvariant, - AccessControl, - ERC2771Context -{ +contract DocumentEngine is DocumentEngineBase, AccessControl, ERC2771Context { /** * @notice * Get the current version of the smart contract */ string public constant VERSION = "0.4.0"; - // Mapping from contract addresses to document names to their corresponding Document structs - mapping(address => mapping(bytes32 => Document)) private _documents; - mapping(address => bytes32[]) private _documentNames; // Constructor to initialize the admin role constructor( @@ -37,220 +33,28 @@ contract DocumentEngine is } /*////////////////////////////////////////////////////////////// - PUBLIC/EXTERNAL FUNCTIONS + ACCESS CONTROL (implementation) //////////////////////////////////////////////////////////////*/ /** - * @notice Restricted function to set or update a document + * @dev Authorization for the admin document-management path. + * The caller must hold `DOCUMENT_MANAGER_ROLE`. Override to customize. */ - function setDocument( - address smartContract, - bytes32 name_, - string memory uri_, - bytes32 documentHash_ - ) public onlyDocumentManager { - _setDocument(smartContract, name_, uri_, documentHash_); - } - - /** - * @notice Restricted function to remove a document for a given smart contract and name - */ - function removeDocument( - address smartContract, - bytes32 name_ - ) external onlyDocumentManager { - _removeDocument(smartContract, name_); - } - - /* ============ ERC-1643 (bound token) ============ */ - - /** - * @notice ERC-1643 function to set or update a document for the caller. - * @dev The document is stored under the caller (`_msgSender()`) namespace. - * The caller must be a token bound to this engine (`TOKEN_CONTRACT_ROLE`), - * following the RuleEngine binding pattern. A bound token can only manage - * its own documents; it can never affect another contract's documents. - */ - function setDocument( - bytes32 name_, - string calldata uri_, - bytes32 documentHash_ - ) external override onlyBoundToken { - _setDocument(_msgSender(), name_, uri_, documentHash_); - } - - /** - * @notice ERC-1643 function to remove a document for the caller. - * @dev See {setDocument}. Scoped to the caller (`_msgSender()`) namespace. - */ - function removeDocument( - bytes32 name_ - ) external override onlyBoundToken { - _removeDocument(_msgSender(), name_); - } - - /** - * @notice Batch version of setDocument to handle multiple documents at once - */ - function batchSetDocuments( - address[] calldata smartContracts, - bytes32[] calldata names, - string[] calldata uris, - bytes32[] calldata hashes - ) external onlyDocumentManager { - if ( - smartContracts.length == 0 || - smartContracts.length != names.length || - names.length != uris.length || - uris.length != hashes.length - ) { - revert InvalidInputLength(); - } - for (uint256 i = 0; i < smartContracts.length; i++) { - _setDocument(smartContracts[i], names[i], uris[i], hashes[i]); - } - } - - /** - * @notice Batch version of setDocument to handle multiple documents at once - */ - function batchSetDocuments( - address smartContract, - bytes32[] calldata names, - string[] calldata uris, - bytes32[] calldata hashes - ) external onlyDocumentManager { - if ( - names.length == 0 || - names.length != uris.length || - uris.length != hashes.length - ) { - revert InvalidInputLength(); - } - for (uint256 i = 0; i < names.length; ++i) { - _setDocument(smartContract, names[i], uris[i], hashes[i]); - } - } - - /** - * @notice Batch version of removeDocument to handle multiple documents at once - */ - function batchRemoveDocuments( - address[] calldata smartContracts, - bytes32[] calldata names - ) external onlyDocumentManager { - if ( - smartContracts.length == 0 || - (smartContracts.length != names.length) - ) { - revert InvalidInputLength(); - } - - for (uint256 i = 0; i < smartContracts.length; ++i) { - _removeDocument(smartContracts[i], names[i]); - } - } - - /** - * @notice Batch version of removeDocument to handle multiple documents at once - */ - function batchRemoveDocuments( - address smartContract, - bytes32[] calldata names - ) external onlyDocumentManager { - if (names.length == 0) { - revert InvalidInputLength(); - } - - for (uint256 i = 0; i < names.length; ++i) { - _removeDocument(smartContract, names[i]); - } - } - - /** - * @notice ERC-1643 function to get a document for the caller (`_msgSender()`) - */ - function getDocument( - bytes32 name_ - ) external view override returns (Document memory) { - return _getDocument(_msgSender(), name_); - } - - /** - * @notice Public function to get a document for a specific contract address - */ - function getDocument( - address smartContract, - bytes32 name_ - ) external view returns (Document memory) { - return _getDocument(smartContract, name_); + function _authorizeDocumentManagement() internal view virtual override { + _checkRole(DOCUMENT_MANAGER_ROLE); } /** - * @notice Get all document names for msg.sender + * @dev Authorization for the bound-token document-management path. + * The caller must hold `TOKEN_CONTRACT_ROLE` (the RuleEngine binding + * pattern). Override to customize. */ - function getAllDocuments() - external + function _authorizeBoundTokenDocumentManagement() + internal view + virtual override - returns (bytes32[] memory) { - return _documentNames[_msgSender()]; - } - - /** - * @notice Get all document names for a specific smart contract - */ - function getAllDocuments( - address smartContract - ) external view returns (bytes32[] memory) { - return _documentNames[smartContract]; - } - - /*////////////////////////////////////////////////////////////// - ACCESS CONTROL (flexible) - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Restricts a function to accounts allowed to manage documents on - * behalf of any smart contract (admin path). - * - * The access-control logic is delegated to {_authorizeDocumentManagement}, - * so the document-management implementation is kept separate from the - * authorization: a subclass can override the hook to change *who* is - * authorized without touching the management functions - * (pattern borrowed from CMTAT and CMTA/RuleEngine). - */ - modifier onlyDocumentManager() { - _authorizeDocumentManagement(); - _; - } - - /** - * @dev Restricts a function to tokens bound to this engine, letting them - * manage their own documents (bound-token path). Delegates to - * {_authorizeBoundTokenDocumentManagement}. - */ - modifier onlyBoundToken() { - _authorizeBoundTokenDocumentManagement(); - _; - } - - /** - * @dev Authorization hook for the admin document-management path. - * Default: the caller must hold `DOCUMENT_MANAGER_ROLE`. - * Override to customize the authorization logic. - */ - function _authorizeDocumentManagement() internal view virtual { - _checkRole(DOCUMENT_MANAGER_ROLE); - } - - /** - * @dev Authorization hook for the bound-token document-management path. - * Default: the caller must hold `TOKEN_CONTRACT_ROLE` (the RuleEngine - * binding pattern). Override to customize the authorization logic. - */ - function _authorizeBoundTokenDocumentManagement() internal view virtual { _checkRole(TOKEN_CONTRACT_ROLE); } @@ -268,80 +72,6 @@ contract DocumentEngine is return AccessControl.hasRole(role, account); } - /*////////////////////////////////////////////////////////////// - INTERNAL FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Internal function to fetch a document - */ - function _getDocument( - address smartContract, - bytes32 name_ - ) internal view returns (Document memory) { - return _documents[smartContract][name_]; - } - - /** - * @dev Internal helper to remove the document name from the list of document names - */ - function _removeDocumentName( - address smartContract, - bytes32 name_ - ) internal { - uint256 length = _documentNames[smartContract].length; - for (uint256 i = 0; i < length; ++i) { - if (_documentNames[smartContract][i] == name_) { - _documentNames[smartContract][i] = _documentNames[ - smartContract - ][length - 1]; - _documentNames[smartContract].pop(); - break; - } - } - } - - function _removeDocument(address smartContract, bytes32 name_) internal { - Document memory doc = _documents[smartContract][name_]; - // Standard ERC-1643 event - emit DocumentRemoved(name_, doc.uri, doc.documentHash); - // Optional multi-token event (see ERC-1643-proposition.md) - emit DocumentRemovedForContract( - smartContract, - name_, - doc.uri, - doc.documentHash - ); - - delete _documents[smartContract][name_]; - _removeDocumentName(smartContract, name_); - } - - function _setDocument( - address smartContract, - bytes32 name_, - string memory uri_, - bytes32 documentHash_ - ) internal { - Document storage doc = _documents[smartContract][name_]; - if (doc.lastModified == 0) { - // new document - _documentNames[smartContract].push(name_); - } - doc.uri = uri_; - doc.documentHash = documentHash_; - doc.lastModified = block.timestamp; - // Standard ERC-1643 event - emit DocumentUpdated(name_, uri_, documentHash_); - // Optional multi-token event (see ERC-1643-proposition.md) - emit DocumentUpdatedForContract( - smartContract, - name_, - uri_, - documentHash_ - ); - } - /*////////////////////////////////////////////////////////////// ERC2771 //////////////////////////////////////////////////////////////*/ diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol new file mode 100644 index 0000000..0e61b4c --- /dev/null +++ b/src/DocumentEngineBase.sol @@ -0,0 +1,311 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "OZ/utils/Context.sol"; +import "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import "./DocumentEngineInvariant.sol"; + +/** + * @title DocumentEngineBase + * @notice Document management logic (ERC-1643) for several smart contracts. + * @dev This abstract base holds the document storage and all the + * document-management functions, but it is **agnostic to the access-control + * implementation**. Authorization is delegated to the abstract hooks + * {_authorizeDocumentManagement} and {_authorizeBoundTokenDocumentManagement} + * (through the `onlyDocumentManager` / `onlyBoundToken` modifiers), which a + * deployment contract must implement (see {DocumentEngine}). + * + * This separation (base logic + deployment-defined access control) follows the + * CMTAT and CMTA/RuleEngine pattern. + */ +abstract contract DocumentEngineBase is + IERC1643, + DocumentEngineInvariant, + Context +{ + // Mapping from contract addresses to document names to their corresponding Document structs + mapping(address => mapping(bytes32 => Document)) private _documents; + mapping(address => bytes32[]) private _documentNames; + + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL (hooks) + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Restricts a function to accounts allowed to manage documents on + * behalf of any smart contract (admin path). Delegates the authorization + * to {_authorizeDocumentManagement} so that the document-management + * implementation stays separate from the access-control logic. + */ + modifier onlyDocumentManager() { + _authorizeDocumentManagement(); + _; + } + + /** + * @dev Restricts a function to tokens bound to this engine, letting them + * manage their own documents (bound-token path). Delegates to + * {_authorizeBoundTokenDocumentManagement}. + */ + modifier onlyBoundToken() { + _authorizeBoundTokenDocumentManagement(); + _; + } + + /** + * @dev Authorization hook for the admin document-management path. + * Implemented by the deployment contract (e.g. a role check). + */ + function _authorizeDocumentManagement() internal view virtual; + + /** + * @dev Authorization hook for the bound-token document-management path. + * Implemented by the deployment contract (e.g. a role check). + */ + function _authorizeBoundTokenDocumentManagement() internal view virtual; + + /*////////////////////////////////////////////////////////////// + PUBLIC/EXTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Restricted function to set or update a document + */ + function setDocument( + address smartContract, + bytes32 name_, + string memory uri_, + bytes32 documentHash_ + ) public onlyDocumentManager { + _setDocument(smartContract, name_, uri_, documentHash_); + } + + /** + * @notice Restricted function to remove a document for a given smart contract and name + */ + function removeDocument( + address smartContract, + bytes32 name_ + ) external onlyDocumentManager { + _removeDocument(smartContract, name_); + } + + /* ============ ERC-1643 (bound token) ============ */ + + /** + * @notice ERC-1643 function to set or update a document for the caller. + * @dev The document is stored under the caller (`_msgSender()`) namespace. + * The caller must be a token bound to this engine (`TOKEN_CONTRACT_ROLE`), + * following the RuleEngine binding pattern. A bound token can only manage + * its own documents; it can never affect another contract's documents. + */ + function setDocument( + bytes32 name_, + string calldata uri_, + bytes32 documentHash_ + ) external override onlyBoundToken { + _setDocument(_msgSender(), name_, uri_, documentHash_); + } + + /** + * @notice ERC-1643 function to remove a document for the caller. + * @dev See {setDocument}. Scoped to the caller (`_msgSender()`) namespace. + */ + function removeDocument( + bytes32 name_ + ) external override onlyBoundToken { + _removeDocument(_msgSender(), name_); + } + + /** + * @notice Batch version of setDocument to handle multiple documents at once + */ + function batchSetDocuments( + address[] calldata smartContracts, + bytes32[] calldata names, + string[] calldata uris, + bytes32[] calldata hashes + ) external onlyDocumentManager { + if ( + smartContracts.length == 0 || + smartContracts.length != names.length || + names.length != uris.length || + uris.length != hashes.length + ) { + revert InvalidInputLength(); + } + for (uint256 i = 0; i < smartContracts.length; i++) { + _setDocument(smartContracts[i], names[i], uris[i], hashes[i]); + } + } + + /** + * @notice Batch version of setDocument to handle multiple documents at once + */ + function batchSetDocuments( + address smartContract, + bytes32[] calldata names, + string[] calldata uris, + bytes32[] calldata hashes + ) external onlyDocumentManager { + if ( + names.length == 0 || + names.length != uris.length || + uris.length != hashes.length + ) { + revert InvalidInputLength(); + } + for (uint256 i = 0; i < names.length; ++i) { + _setDocument(smartContract, names[i], uris[i], hashes[i]); + } + } + + /** + * @notice Batch version of removeDocument to handle multiple documents at once + */ + function batchRemoveDocuments( + address[] calldata smartContracts, + bytes32[] calldata names + ) external onlyDocumentManager { + if ( + smartContracts.length == 0 || + (smartContracts.length != names.length) + ) { + revert InvalidInputLength(); + } + + for (uint256 i = 0; i < smartContracts.length; ++i) { + _removeDocument(smartContracts[i], names[i]); + } + } + + /** + * @notice Batch version of removeDocument to handle multiple documents at once + */ + function batchRemoveDocuments( + address smartContract, + bytes32[] calldata names + ) external onlyDocumentManager { + if (names.length == 0) { + revert InvalidInputLength(); + } + + for (uint256 i = 0; i < names.length; ++i) { + _removeDocument(smartContract, names[i]); + } + } + + /** + * @notice ERC-1643 function to get a document for the caller (`_msgSender()`) + */ + function getDocument( + bytes32 name_ + ) external view override returns (Document memory) { + return _getDocument(_msgSender(), name_); + } + + /** + * @notice Public function to get a document for a specific contract address + */ + function getDocument( + address smartContract, + bytes32 name_ + ) external view returns (Document memory) { + return _getDocument(smartContract, name_); + } + + /** + * @notice Get all document names for msg.sender + */ + function getAllDocuments() + external + view + override + returns (bytes32[] memory) + { + return _documentNames[_msgSender()]; + } + + /** + * @notice Get all document names for a specific smart contract + */ + function getAllDocuments( + address smartContract + ) external view returns (bytes32[] memory) { + return _documentNames[smartContract]; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Internal function to fetch a document + */ + function _getDocument( + address smartContract, + bytes32 name_ + ) internal view returns (Document memory) { + return _documents[smartContract][name_]; + } + + /** + * @dev Internal helper to remove the document name from the list of document names + */ + function _removeDocumentName( + address smartContract, + bytes32 name_ + ) internal { + uint256 length = _documentNames[smartContract].length; + for (uint256 i = 0; i < length; ++i) { + if (_documentNames[smartContract][i] == name_) { + _documentNames[smartContract][i] = _documentNames[ + smartContract + ][length - 1]; + _documentNames[smartContract].pop(); + break; + } + } + } + + function _removeDocument(address smartContract, bytes32 name_) internal { + Document memory doc = _documents[smartContract][name_]; + // Standard ERC-1643 event + emit DocumentRemoved(name_, doc.uri, doc.documentHash); + // Optional multi-token event (see ERC-1643-proposition.md) + emit DocumentRemovedForContract( + smartContract, + name_, + doc.uri, + doc.documentHash + ); + + delete _documents[smartContract][name_]; + _removeDocumentName(smartContract, name_); + } + + function _setDocument( + address smartContract, + bytes32 name_, + string memory uri_, + bytes32 documentHash_ + ) internal { + Document storage doc = _documents[smartContract][name_]; + if (doc.lastModified == 0) { + // new document + _documentNames[smartContract].push(name_); + } + doc.uri = uri_; + doc.documentHash = documentHash_; + doc.lastModified = block.timestamp; + // Standard ERC-1643 event + emit DocumentUpdated(name_, uri_, documentHash_); + // Optional multi-token event (see ERC-1643-proposition.md) + emit DocumentUpdatedForContract( + smartContract, + name_, + uri_, + documentHash_ + ); + } +} From f544ce5f3df2ae48bff8bfd05494b45f07c6526c Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:56:12 +0200 Subject: [PATCH 04/19] feat: add VersionModule implementing ERC-8303 (version() + ERC-165) --- AGENTS.md | 17 ++++++++----- CHANGELOG.md | 1 + CLAUDE.md | 17 ++++++++----- README.md | 25 ++++++++++++++++++- src/DocumentEngine.sol | 27 ++++++++++++++------ src/interfaces/IERC8303.sol | 14 +++++++++++ src/modules/VersionModule.sol | 47 +++++++++++++++++++++++++++++++++++ test/DocumentEngine.t.sol | 35 ++++++++++++++++++++++++++ 8 files changed, 162 insertions(+), 21 deletions(-) create mode 100644 src/interfaces/IERC8303.sol create mode 100644 src/modules/VersionModule.sol diff --git a/AGENTS.md b/AGENTS.md index 1791154..62f2967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,10 +53,15 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL -│ # (AccessControl, _authorize* impls, hasRole) + ERC-2771, -│ # VERSION, constructor -└── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, - # TOKEN_CONTRACT_ROLE) and the optional multi-token events +│ # (AccessControl, _authorize* impls, hasRole), ERC-2771, +│ # combined supportsInterface, constructor +├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, +│ # TOKEN_CONTRACT_ROLE) and the optional multi-token events +├── interfaces/ +│ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +└── modules/ + └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, + # holds the VERSION constant (currently "0.4.0") test/ └── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, @@ -97,8 +102,8 @@ forge test --gas-report ## Conventions -- The contract `VERSION` constant (in `src/DocumentEngine.sol`) must match the - latest `CHANGELOG.md` entry on release. +- The `VERSION` constant (in `src/modules/VersionModule.sol`, exposed via + ERC-8303 `version()`) must match the latest `CHANGELOG.md` entry on release. - Bump `MAJOR` on incompatible proxy-storage / external-library or API changes, `MINOR` for backward-compatible features, `PATCH` for backward-compatible fixes. - A bound token can only ever affect its **own** document namespace — never break diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b5a72..9a6b332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. - **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. - **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. +- **Version module implementing ERC-8303**: the version is now exposed through a dedicated `VersionModule` (`src/modules/VersionModule.sol`) implementing the `IERC8303` interface (`src/interfaces/IERC8303.sol`). It adds a standard `version()` view function (in addition to the existing public `VERSION` constant) and advertises ERC-8303 via ERC-165 (`supportsInterface(0x54fd4d50) == true`). `DocumentEngine` combines the module's `supportsInterface` with `AccessControl`. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index 1791154..62f2967 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,10 +53,15 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL -│ # (AccessControl, _authorize* impls, hasRole) + ERC-2771, -│ # VERSION, constructor -└── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, - # TOKEN_CONTRACT_ROLE) and the optional multi-token events +│ # (AccessControl, _authorize* impls, hasRole), ERC-2771, +│ # combined supportsInterface, constructor +├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, +│ # TOKEN_CONTRACT_ROLE) and the optional multi-token events +├── interfaces/ +│ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +└── modules/ + └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, + # holds the VERSION constant (currently "0.4.0") test/ └── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, @@ -97,8 +102,8 @@ forge test --gas-report ## Conventions -- The contract `VERSION` constant (in `src/DocumentEngine.sol`) must match the - latest `CHANGELOG.md` entry on release. +- The `VERSION` constant (in `src/modules/VersionModule.sol`, exposed via + ERC-8303 `version()`) must match the latest `CHANGELOG.md` entry on release. - Bump `MAJOR` on incompatible proxy-storage / external-library or API changes, `MINOR` for backward-compatible features, `PATCH` for backward-compatible fixes. - A bound token can only ever affect its **own** document namespace — never break diff --git a/README.md b/README.md index 28b9272..fc70b03 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,29 @@ The engine is split into two contracts (CMTAT module/deployment pattern): `DocumentEngineInvariant` provides the shared errors, roles (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) and the optional multi-token events. +`VersionModule` (`src/modules/VersionModule.sol`) isolates the version concern +and implements [ERC-8303](https://ethereum-magicians.org/t/erc-8303-contract-version/28795) +(see below). + +## Version (ERC-8303) + +The contract version is exposed through the `VersionModule`, which implements +the [ERC-8303](https://ethereum-magicians.org/t/erc-8303-contract-version/28795) +`IERC8303` interface: + +```solidity +interface IERC8303 { + function version() external view returns (string memory); +} +``` + +- `version()` returns the current version string (e.g. `"0.4.0"`), following + Semantic Versioning 2.0.0. +- The public `VERSION` constant is kept for backward compatibility and returns + the same value. +- ERC-165 discovery is supported: `supportsInterface(0x54fd4d50)` (the ERC-8303 + interface id) returns `true`. + ## Schema ### Inheritance @@ -148,7 +171,7 @@ The engine is split into two contracts (CMTAT module/deployment pattern): | :----------------: | :------------------: | :----------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DocumentEngine** | Implementation | DocumentEngineBase, AccessControl, ERC2771Context | | | +| **DocumentEngine** | Implementation | DocumentEngineBase, VersionModule, AccessControl, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | | └ | setDocument | Public ❗️ | 🛑 | onlyDocumentManager | | └ | removeDocument | External ❗️ | 🛑 | onlyDocumentManager | diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 06bab95..e28932e 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import "OZ/access/AccessControl.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; +import "./modules/VersionModule.sol"; /** * @title DocumentEngine @@ -11,16 +12,16 @@ import "./DocumentEngineBase.sol"; * @dev Wires the document-management logic ({DocumentEngineBase}) with a * concrete access-control implementation. The authorization hooks are defined * here (role-based `AccessControl`), keeping the access control separate from - * the document-management logic (CMTAT / CMTA-RuleEngine pattern). It also wires + * the document-management logic (CMTAT / CMTA-RuleEngine pattern). The contract + * version is exposed through the {VersionModule} (ERC-8303), and it also wires * the ERC-2771 (gasless) meta-transaction support. */ -contract DocumentEngine is DocumentEngineBase, AccessControl, ERC2771Context { - /** - * @notice - * Get the current version of the smart contract - */ - string public constant VERSION = "0.4.0"; - +contract DocumentEngine is + DocumentEngineBase, + VersionModule, + AccessControl, + ERC2771Context +{ // Constructor to initialize the admin role constructor( address admin, @@ -72,6 +73,16 @@ contract DocumentEngine is DocumentEngineBase, AccessControl, ERC2771Context { return AccessControl.hasRole(role, account); } + /** + * @dev Combines the ERC-165 interface discovery of the version module + * (ERC-8303) with `AccessControl`. See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(VersionModule, AccessControl) returns (bool) { + return super.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// ERC2771 //////////////////////////////////////////////////////////////*/ diff --git a/src/interfaces/IERC8303.sol b/src/interfaces/IERC8303.sol new file mode 100644 index 0000000..a52a77c --- /dev/null +++ b/src/interfaces/IERC8303.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +/** + * @title IERC8303 - Contract Version + * @notice Interface for exposing a contract implementation version string. + * @dev ERC-8303 (Draft) — https://ethereum-magicians.org/t/erc-8303-contract-version/28795 + * The interface id is `0x54fd4d50` (the `version()` selector). + */ +interface IERC8303 { + /// @notice Returns the implementation version string. + /// @return The version value, for example "1.0.0". + function version() external view returns (string memory); +} diff --git a/src/modules/VersionModule.sol b/src/modules/VersionModule.sol new file mode 100644 index 0000000..86ce656 --- /dev/null +++ b/src/modules/VersionModule.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {ERC165} from "OZ/utils/introspection/ERC165.sol"; +import {IERC8303} from "../interfaces/IERC8303.sol"; + +/** + * @title VersionModule + * @notice Exposes the current contract version through ERC-8303 (`version()`), + * with optional ERC-165 interface discovery. + * @dev Implements ERC-8303 (Draft). The version string is defined here so the + * version concern is isolated in a dedicated module (CMTAT pattern). A deployment + * contract that also implements ERC-165 must combine this module's + * {supportsInterface} with the others it inherits. + */ +abstract contract VersionModule is IERC8303, ERC165 { + /** + * @notice Get the current version of the smart contract. + * @dev Follows Semantic Versioning 2.0.0 (`MAJOR.MINOR.PATCH`). + */ + string public constant VERSION = "0.4.0"; + + /** + * @inheritdoc IERC8303 + */ + function version() + public + view + virtual + override(IERC8303) + returns (string memory version_) + { + return VERSION; + } + + /** + * @dev Advertises ERC-8303 support (interface id `0x54fd4d50`). + * See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override returns (bool) { + return + interfaceId == type(IERC8303).interfaceId || + super.supportsInterface(interfaceId); + } +} diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index 7104913..c0c61e9 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -5,6 +5,8 @@ import "forge-std/Test.sol"; import "../src/DocumentEngine.sol"; import "../src/DocumentEngineInvariant.sol"; import "OZ/access/AccessControl.sol"; +import {IERC165} from "OZ/utils/introspection/IERC165.sol"; +import {IERC8303} from "../src/interfaces/IERC8303.sol"; import {DocumentEngineModule} from "CMTAT/modules/wrapper/options/DocumentEngineModule.sol"; /** @@ -306,6 +308,39 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { assertEq(doc.documentHash, documentHash); } + /*////////////////////////////////////////////////////////////// + Version (ERC-8303) + //////////////////////////////////////////////////////////////*/ + + function testVersionReturnsNonEmptyString() public { + string memory v = documentEngine.version(); + assertGt(bytes(v).length, 0); + assertEq(v, "0.4.0"); + // the public VERSION constant matches version() + assertEq(documentEngine.VERSION(), v); + } + + function testSupportsInterfaceERC8303() public { + // interface id declared by ERC-8303 + assertEq(type(IERC8303).interfaceId, bytes4(0x54fd4d50)); + assertTrue( + documentEngine.supportsInterface(type(IERC8303).interfaceId) + ); + } + + function testSupportsInterfaceERC165AndAccessControl() public { + assertTrue( + documentEngine.supportsInterface(type(IERC165).interfaceId) + ); + assertTrue( + documentEngine.supportsInterface(type(IAccessControl).interfaceId) + ); + } + + function testDoesNotSupportInvalidInterface() public { + assertFalse(documentEngine.supportsInterface(bytes4(0xffffffff))); + } + /*////////////////////////////////////////////////////////////// Set documents //////////////////////////////////////////////////////////////*/ From 183b648d742af518243c3820e0da65f1d935ef7f Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:01:35 +0200 Subject: [PATCH 05/19] feat: use AccessControlEnumerable and add DocumentEngineOwnable (Ownable2Step) deployment --- AGENTS.md | 26 +++--- CHANGELOG.md | 7 +- CLAUDE.md | 26 +++--- README.md | 13 ++- src/DocumentEngine.sol | 26 ++++-- src/DocumentEngineOwnable.sol | 131 ++++++++++++++++++++++++++++ test/DocumentEngineOwnable.t.sol | 144 +++++++++++++++++++++++++++++++ 7 files changed, 339 insertions(+), 34 deletions(-) create mode 100644 src/DocumentEngineOwnable.sol create mode 100644 test/DocumentEngineOwnable.t.sol diff --git a/AGENTS.md b/AGENTS.md index 62f2967..94112be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,9 +52,11 @@ src/ ├── DocumentEngineBase.sol # Abstract base: ERC-1643 document logic + storage, │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) -├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL -│ # (AccessControl, _authorize* impls, hasRole), ERC-2771, -│ # combined supportsInterface, constructor +├── DocumentEngine.sol # Deployment #1: role-based access control +│ # (AccessControlEnumerable, _authorize* impls, hasRole), +│ # ERC-2771, combined supportsInterface, constructor +├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of +│ # roles; owner-managed token binding (setTokenBinding) ├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, │ # TOKEN_CONTRACT_ROLE) and the optional multi-token events ├── interfaces/ @@ -64,16 +66,20 @@ src/ # holds the VERSION constant (currently "0.4.0") test/ -└── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, - # bound-token path, batch ops, CMTAT integration - # (CMTATDocumentEngineMock built on DocumentEngineModule), - # flexible-authorization override (OpenDocumentEngine) +├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, +│ # bound-token path, batch ops, ERC-8303, CMTAT integration +│ # (CMTATDocumentEngineMock built on DocumentEngineModule), +│ # flexible-authorization override (OpenDocumentEngine) +└── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, + # token binding, two-step ownership, ERC-8303) ``` **Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds -the document logic and abstract `_authorize*` hooks; `DocumentEngine` is the -deployable contract that supplies the concrete access control. Add new -management logic in the base; change *who* is authorized in the deployment. +the document logic and abstract `_authorize*` hooks; each deployment contract +supplies the concrete access control. There are two deployments — +`DocumentEngine` (role-based, `AccessControlEnumerable`) and +`DocumentEngineOwnable` (`Ownable2Step`). Add new management logic in the base; +change *who* is authorized in a deployment (implement the `_authorize*` hooks). Other important files: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6b332..4f74716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,7 +61,12 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. - **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. - **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. -- **Version module implementing ERC-8303**: the version is now exposed through a dedicated `VersionModule` (`src/modules/VersionModule.sol`) implementing the `IERC8303` interface (`src/interfaces/IERC8303.sol`). It adds a standard `version()` view function (in addition to the existing public `VERSION` constant) and advertises ERC-8303 via ERC-165 (`supportsInterface(0x54fd4d50) == true`). `DocumentEngine` combines the module's `supportsInterface` with `AccessControl`. +- **Version module implementing ERC-8303**: the version is now exposed through a dedicated `VersionModule` (`src/modules/VersionModule.sol`) implementing the `IERC8303` interface (`src/interfaces/IERC8303.sol`). It adds a standard `version()` view function (in addition to the existing public `VERSION` constant) and advertises ERC-8303 via ERC-165 (`supportsInterface(0x54fd4d50) == true`). `DocumentEngine` combines the module's `supportsInterface` with the access-control base. +- **Second deployment `DocumentEngineOwnable`** (`src/DocumentEngineOwnable.sol`): an alternative deployment that uses OpenZeppelin `Ownable2Step` (single owner, two-step transfer) instead of role-based access control, reusing the same `DocumentEngineBase` logic. Admin document management is restricted to the `owner`; the bound-token path uses an owner-managed binding allowlist (`setTokenBinding` / `isBoundToken`, the analog of `TOKEN_CONTRACT_ROLE`, reverting with `NotBoundToken`). + +### Changed (access control) + +- `DocumentEngine` now inherits **`AccessControlEnumerable`** instead of `AccessControl`, adding on-chain enumeration of role members (`getRoleMember`, `getRoleMemberCount`) and advertising `IAccessControlEnumerable` via ERC-165. Default authorization behavior is unchanged. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index 62f2967..94112be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,11 @@ src/ ├── DocumentEngineBase.sol # Abstract base: ERC-1643 document logic + storage, │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) -├── DocumentEngine.sol # Deployment contract: defines the ACCESS CONTROL -│ # (AccessControl, _authorize* impls, hasRole), ERC-2771, -│ # combined supportsInterface, constructor +├── DocumentEngine.sol # Deployment #1: role-based access control +│ # (AccessControlEnumerable, _authorize* impls, hasRole), +│ # ERC-2771, combined supportsInterface, constructor +├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of +│ # roles; owner-managed token binding (setTokenBinding) ├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, │ # TOKEN_CONTRACT_ROLE) and the optional multi-token events ├── interfaces/ @@ -64,16 +66,20 @@ src/ # holds the VERSION constant (currently "0.4.0") test/ -└── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, - # bound-token path, batch ops, CMTAT integration - # (CMTATDocumentEngineMock built on DocumentEngineModule), - # flexible-authorization override (OpenDocumentEngine) +├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, +│ # bound-token path, batch ops, ERC-8303, CMTAT integration +│ # (CMTATDocumentEngineMock built on DocumentEngineModule), +│ # flexible-authorization override (OpenDocumentEngine) +└── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, + # token binding, two-step ownership, ERC-8303) ``` **Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds -the document logic and abstract `_authorize*` hooks; `DocumentEngine` is the -deployable contract that supplies the concrete access control. Add new -management logic in the base; change *who* is authorized in the deployment. +the document logic and abstract `_authorize*` hooks; each deployment contract +supplies the concrete access control. There are two deployments — +`DocumentEngine` (role-based, `AccessControlEnumerable`) and +`DocumentEngineOwnable` (`Ownable2Step`). Add new management logic in the base; +change *who* is authorized in a deployment (implement the `_authorize*` hooks). Other important files: diff --git a/README.md b/README.md index fc70b03..229a559 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,14 @@ The engine is split into two contracts (CMTAT module/deployment pattern): `onlyBoundToken` modifiers and the **abstract** `_authorize*` hooks. It is agnostic to the access-control implementation. - **`DocumentEngine`** (deployment) — the concrete, deployable contract. It - defines the **access control** (`AccessControl`, the `_authorize*` hook - implementations and the `hasRole` override) and wires the ERC-2771 (gasless) - support. + defines the **access control** (`AccessControlEnumerable`, the `_authorize*` + hook implementations and the `hasRole` override) and wires the ERC-2771 + (gasless) support. `AccessControlEnumerable` additionally allows enumerating + the members of each role on-chain. +- **`DocumentEngineOwnable`** (alternative deployment) — same base logic, but + access control is a single **owner** via `Ownable2Step` (two-step ownership + transfer) instead of roles. Admin management is `owner`-only; the bound-token + path uses an owner-managed allowlist (`setTokenBinding` / `isBoundToken`). `DocumentEngineInvariant` provides the shared errors, roles (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) and the optional multi-token events. @@ -171,7 +176,7 @@ interface IERC8303 { | :----------------: | :------------------: | :----------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DocumentEngine** | Implementation | DocumentEngineBase, VersionModule, AccessControl, ERC2771Context | | | +| **DocumentEngine** | Implementation | DocumentEngineBase, VersionModule, AccessControlEnumerable, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | | └ | setDocument | Public ❗️ | 🛑 | onlyDocumentManager | | └ | removeDocument | External ❗️ | 🛑 | onlyDocumentManager | diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index e28932e..3c62b8a 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -1,7 +1,8 @@ //SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; -import "OZ/access/AccessControl.sol"; +import "OZ/access/extensions/AccessControlEnumerable.sol"; +import {IAccessControl} from "OZ/access/IAccessControl.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; import "./modules/VersionModule.sol"; @@ -11,15 +12,16 @@ import "./modules/VersionModule.sol"; * @notice Deployment contract to manage documents on-chain through ERC-1643. * @dev Wires the document-management logic ({DocumentEngineBase}) with a * concrete access-control implementation. The authorization hooks are defined - * here (role-based `AccessControl`), keeping the access control separate from - * the document-management logic (CMTAT / CMTA-RuleEngine pattern). The contract + * here (role-based `AccessControlEnumerable`, which additionally allows + * enumerating role members), keeping the access control separate from the + * document-management logic (CMTAT / CMTA-RuleEngine pattern). The contract * version is exposed through the {VersionModule} (ERC-8303), and it also wires * the ERC-2771 (gasless) meta-transaction support. */ contract DocumentEngine is DocumentEngineBase, VersionModule, - AccessControl, + AccessControlEnumerable, ERC2771Context { // Constructor to initialize the admin role @@ -65,21 +67,27 @@ contract DocumentEngine is function hasRole( bytes32 role, address account - ) public view virtual override returns (bool) { + ) public view virtual override(AccessControl, IAccessControl) returns (bool) { // The Default Admin has all roles - if (AccessControl.hasRole(DEFAULT_ADMIN_ROLE, account)) { + if (super.hasRole(DEFAULT_ADMIN_ROLE, account)) { return true; } - return AccessControl.hasRole(role, account); + return super.hasRole(role, account); } /** * @dev Combines the ERC-165 interface discovery of the version module - * (ERC-8303) with `AccessControl`. See {IERC165-supportsInterface}. + * (ERC-8303) with `AccessControlEnumerable`. See {IERC165-supportsInterface}. */ function supportsInterface( bytes4 interfaceId - ) public view virtual override(VersionModule, AccessControl) returns (bool) { + ) + public + view + virtual + override(VersionModule, AccessControlEnumerable) + returns (bool) + { return super.supportsInterface(interfaceId); } diff --git a/src/DocumentEngineOwnable.sol b/src/DocumentEngineOwnable.sol new file mode 100644 index 0000000..976ea3c --- /dev/null +++ b/src/DocumentEngineOwnable.sol @@ -0,0 +1,131 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Ownable} from "OZ/access/Ownable.sol"; +import {Ownable2Step} from "OZ/access/Ownable2Step.sol"; +import "OZ/metatx/ERC2771Context.sol"; +import "./DocumentEngineBase.sol"; +import "./modules/VersionModule.sol"; + +/** + * @title DocumentEngineOwnable + * @notice Alternative deployment of the DocumentEngine that uses a single owner + * ({Ownable2Step}) instead of role-based access control. + * @dev Reuses the same document-management logic ({DocumentEngineBase}) and only + * swaps the access-control implementation, illustrating the base/deployment + * separation: + * - admin path (`onlyDocumentManager`): restricted to the `owner`; + * - bound-token path (`onlyBoundToken`): restricted to tokens the owner has + * bound to the engine (owner-managed allowlist, the analog of the role-based + * `TOKEN_CONTRACT_ROLE` binding). A bound token manages only its own documents. + * Ownership uses the two-step transfer flow for safety, and the contract also + * exposes its version through ERC-8303 ({VersionModule}) and wires ERC-2771. + */ +contract DocumentEngineOwnable is + DocumentEngineBase, + VersionModule, + Ownable2Step, + ERC2771Context +{ + /// @dev Tokens bound to the engine, allowed to manage their own documents. + mapping(address => bool) private _boundTokens; + + /// @notice Emitted when a token binding is set or removed by the owner. + event TokenBindingSet(address indexed token, bool bound); + + /// @notice Thrown when a non-bound caller uses the bound-token path. + error NotBoundToken(address caller); + + /** + * @param owner_ initial owner of the contract + * @param forwarderIrrevocable address of the ERC-2771 forwarder (gasless support) + */ + constructor( + address owner_, + address forwarderIrrevocable + ) Ownable(owner_) ERC2771Context(forwarderIrrevocable) {} + + /*////////////////////////////////////////////////////////////// + TOKEN BINDING + //////////////////////////////////////////////////////////////*/ + + /** + * @notice Bind or unbind a token, allowing it to manage its own documents. + * @dev Owner-managed analog of granting/revoking `TOKEN_CONTRACT_ROLE`. + */ + function setTokenBinding(address token, bool bound) external onlyOwner { + _boundTokens[token] = bound; + emit TokenBindingSet(token, bound); + } + + /// @notice Returns whether `token` is bound to the engine. + function isBoundToken(address token) external view returns (bool) { + return _boundTokens[token]; + } + + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL (implementation) + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Authorization for the admin document-management path: only the owner. + */ + function _authorizeDocumentManagement() internal view virtual override { + _checkOwner(); + } + + /** + * @dev Authorization for the bound-token document-management path: the + * caller must be a token bound by the owner. + */ + function _authorizeBoundTokenDocumentManagement() + internal + view + virtual + override + { + if (!_boundTokens[_msgSender()]) { + revert NotBoundToken(_msgSender()); + } + } + + /*////////////////////////////////////////////////////////////// + ERC2771 + //////////////////////////////////////////////////////////////*/ + + /** + * @dev This surcharge is not necessary if you do not use ERC2771 + */ + function _msgSender() + internal + view + override(ERC2771Context, Context) + returns (address sender) + { + return ERC2771Context._msgSender(); + } + + /** + * @dev This surcharge is not necessary if you do not use ERC2771 + */ + function _msgData() + internal + view + override(ERC2771Context, Context) + returns (bytes calldata) + { + return ERC2771Context._msgData(); + } + + /** + * @dev This surcharge is not necessary if you do not use the MetaTxModule + */ + function _contextSuffixLength() + internal + view + override(ERC2771Context, Context) + returns (uint256) + { + return ERC2771Context._contextSuffixLength(); + } +} diff --git a/test/DocumentEngineOwnable.t.sol b/test/DocumentEngineOwnable.t.sol new file mode 100644 index 0000000..3117c94 --- /dev/null +++ b/test/DocumentEngineOwnable.t.sol @@ -0,0 +1,144 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import "../src/DocumentEngineOwnable.sol"; +import {Ownable} from "OZ/access/Ownable.sol"; +import {IAccessControl} from "OZ/access/IAccessControl.sol"; +import {IERC165} from "OZ/utils/introspection/IERC165.sol"; +import {IERC8303} from "../src/interfaces/IERC8303.sol"; + +contract DocumentEngineOwnableTest is Test { + DocumentEngineOwnable public engine; + address public owner = address(0x1); + address public newOwner = address(0x2); + address public attacker = address(0x3); + address private testContract = address(0x4); + bytes32 public documentName = keccak256("doc1"); + string public documentURI = "https://example.com/doc1"; + bytes32 public documentHash = keccak256("doc1Hash"); + address AddressZero = address(0); + + function setUp() public { + engine = new DocumentEngineOwnable(owner, AddressZero); + } + + /* ============ DEPLOYMENT ============ */ + + function testDeploySetsOwner() public { + assertEq(engine.owner(), owner); + } + + function testDeployRevertsWithZeroOwner() public { + vm.expectRevert( + abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, AddressZero) + ); + new DocumentEngineOwnable(AddressZero, AddressZero); + } + + /* ============ ADMIN PATH (owner) ============ */ + + function testOwnerCanSetAndRemoveDocument() public { + vm.prank(owner); + engine.setDocument(testContract, documentName, documentURI, documentHash); + + IERC1643.Document memory doc = engine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, documentURI); + assertEq(doc.documentHash, documentHash); + assertEq(doc.lastModified, block.timestamp); + + vm.prank(owner); + engine.removeDocument(testContract, documentName); + doc = engine.getDocument(testContract, documentName); + assertEq(doc.lastModified, 0); + } + + function testNonOwnerCannotSetDocument() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, + attacker + ) + ); + engine.setDocument(testContract, documentName, documentURI, documentHash); + } + + /* ============ BOUND-TOKEN PATH ============ */ + + function testOwnerCanBindToken() public { + vm.prank(owner); + engine.setTokenBinding(testContract, true); + assertTrue(engine.isBoundToken(testContract)); + } + + function testNonOwnerCannotBindToken() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + Ownable.OwnableUnauthorizedAccount.selector, + attacker + ) + ); + engine.setTokenBinding(testContract, true); + } + + function testBoundTokenCanManageOwnDocument() public { + vm.prank(owner); + engine.setTokenBinding(testContract, true); + + vm.prank(testContract); + engine.setDocument(documentName, documentURI, documentHash); + + IERC1643.Document memory doc = engine.getDocument( + testContract, + documentName + ); + assertEq(doc.uri, documentURI); + + vm.prank(testContract); + engine.removeDocument(documentName); + doc = engine.getDocument(testContract, documentName); + assertEq(doc.lastModified, 0); + } + + function testUnboundTokenCannotSelfManage() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + DocumentEngineOwnable.NotBoundToken.selector, + attacker + ) + ); + engine.setDocument(documentName, documentURI, documentHash); + } + + /* ============ TWO-STEP OWNERSHIP ============ */ + + function testTwoStepOwnershipTransfer() public { + vm.prank(owner); + engine.transferOwnership(newOwner); + // ownership not transferred until accepted + assertEq(engine.owner(), owner); + assertEq(engine.pendingOwner(), newOwner); + + vm.prank(newOwner); + engine.acceptOwnership(); + assertEq(engine.owner(), newOwner); + assertEq(engine.pendingOwner(), AddressZero); + } + + /* ============ VERSION (ERC-8303) ============ */ + + function testVersionAndInterface() public { + assertEq(engine.version(), "0.4.0"); + assertTrue(engine.supportsInterface(type(IERC8303).interfaceId)); + assertTrue(engine.supportsInterface(type(IERC165).interfaceId)); + // no role-based access control here + assertFalse(engine.supportsInterface(type(IAccessControl).interfaceId)); + assertFalse(engine.supportsInterface(bytes4(0xffffffff))); + } +} From 1b9e49e0dfd86879817e42cacd2be7e3637fa157 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:08:43 +0200 Subject: [PATCH 06/19] refactor: move role constants from DocumentEngineInvariant to DocumentEngine (role-free base/Ownable) --- AGENTS.md | 10 ++++++---- CHANGELOG.md | 1 + CLAUDE.md | 10 ++++++---- README.md | 6 ++++-- src/DocumentEngine.sol | 10 ++++++++++ src/DocumentEngineInvariant.sol | 18 ++++++++---------- test/DocumentEngine.t.sol | 6 ++++++ 7 files changed, 41 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 94112be..76d8537 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,12 +53,14 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment #1: role-based access control -│ # (AccessControlEnumerable, _authorize* impls, hasRole), -│ # ERC-2771, combined supportsInterface, constructor +│ # (AccessControlEnumerable, the role constants +│ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* +│ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of │ # roles; owner-managed token binding (setTokenBinding) -├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, -│ # TOKEN_CONTRACT_ROLE) and the optional multi-token events +├── DocumentEngineInvariant.sol # Shared errors + optional multi-token events only +│ # (NO access-control specifics — roles live in the +│ # role-based deployment) ├── interfaces/ │ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) └── modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f74716..d4b9cf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) ### Changed (access control) - `DocumentEngine` now inherits **`AccessControlEnumerable`** instead of `AccessControl`, adding on-chain enumeration of role members (`getRoleMember`, `getRoleMemberCount`) and advertising `IAccessControlEnumerable` via ERC-165. Default authorization behavior is unchanged. +- Moved the role constants `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE` out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors and multi-token events. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index 94112be..76d8537 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,12 +53,14 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment #1: role-based access control -│ # (AccessControlEnumerable, _authorize* impls, hasRole), -│ # ERC-2771, combined supportsInterface, constructor +│ # (AccessControlEnumerable, the role constants +│ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* +│ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of │ # roles; owner-managed token binding (setTokenBinding) -├── DocumentEngineInvariant.sol # Errors, roles (DOCUMENT_MANAGER_ROLE, -│ # TOKEN_CONTRACT_ROLE) and the optional multi-token events +├── DocumentEngineInvariant.sol # Shared errors + optional multi-token events only +│ # (NO access-control specifics — roles live in the +│ # role-based deployment) ├── interfaces/ │ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) └── modules/ diff --git a/README.md b/README.md index 229a559..4cfbfc1 100644 --- a/README.md +++ b/README.md @@ -126,8 +126,10 @@ The engine is split into two contracts (CMTAT module/deployment pattern): transfer) instead of roles. Admin management is `owner`-only; the bound-token path uses an owner-managed allowlist (`setTokenBinding` / `isBoundToken`). -`DocumentEngineInvariant` provides the shared errors, roles -(`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) and the optional multi-token events. +`DocumentEngineInvariant` provides the errors and the optional multi-token events +shared by every deployment. Access-control specifics are **not** defined there: +the role constants (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) live in the +role-based `DocumentEngine`, and the owner/binding logic in `DocumentEngineOwnable`. `VersionModule` (`src/modules/VersionModule.sol`) isolates the version concern and implements [ERC-8303](https://ethereum-magicians.org/t/erc-8303-contract-version/28795) diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 3c62b8a..d1d16ff 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -24,6 +24,16 @@ contract DocumentEngine is AccessControlEnumerable, ERC2771Context { + // Role allowed to manage documents on behalf of any smart contract (admin path) + bytes32 public constant DOCUMENT_MANAGER_ROLE = + keccak256("DOCUMENT_MANAGER_ROLE"); + + // Role granted to a token bound to the engine, allowing it to manage its own + // documents through the standard ERC-1643 functions (msg.sender is the token). + // Mirrors the RuleEngine binding pattern (CMTA/RuleEngine `TOKEN_CONTRACT_ROLE`). + bytes32 public constant TOKEN_CONTRACT_ROLE = + keccak256("TOKEN_CONTRACT_ROLE"); + // Constructor to initialize the admin role constructor( address admin, diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index 2d229fd..a554bfd 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -1,6 +1,14 @@ //SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; +/** + * @title DocumentEngineInvariant + * @notice Shared errors and events for the DocumentEngine, common to every + * deployment regardless of its access-control model. + * @dev Access-control specifics (roles, owner, ...) are intentionally NOT + * defined here; they belong to the deployment contract (e.g. the role constants + * live in {DocumentEngine}, the owner logic in {DocumentEngineOwnable}). + */ contract DocumentEngineInvariant { error DocumentNotFound(address smartContract, bytes32 name); error InvalidInputLength(); @@ -27,14 +35,4 @@ contract DocumentEngineInvariant { string uri, bytes32 documentHash ); - - // Role allowed to manage documents on behalf of any smart contract (admin path) - bytes32 public constant DOCUMENT_MANAGER_ROLE = - keccak256("DOCUMENT_MANAGER_ROLE"); - - // Role granted to a token bound to the engine, allowing it to manage its own - // documents through the standard ERC-1643 functions (msg.sender is the token). - // Mirrors the RuleEngine binding pattern (CMTA/RuleEngine `TOKEN_CONTRACT_ROLE`). - bytes32 public constant TOKEN_CONTRACT_ROLE = - keccak256("TOKEN_CONTRACT_ROLE"); } diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index c0c61e9..fdebd21 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -50,6 +50,12 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { string public documentURI = "https://example.com/doc1"; bytes32 public documentHash = keccak256("doc1Hash"); bytes32 public constant DOCUMENT_ROLE = keccak256("DOCUMENT_ROLE"); + // Roles are defined on the role-based deployment (DocumentEngine), not on the + // shared DocumentEngineInvariant; mirrored here for the assertions. + bytes32 public constant DOCUMENT_MANAGER_ROLE = + keccak256("DOCUMENT_MANAGER_ROLE"); + bytes32 public constant TOKEN_CONTRACT_ROLE = + keccak256("TOKEN_CONTRACT_ROLE"); address AddressZero = address(0); function setUp() public { From a6a8a7c225d357fad79246b8bc604a9f7c6df30a Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:31:34 +0200 Subject: [PATCH 07/19] revert: keep purpose-built token binding; document why RuleEngine's ERC-3643 compliance module is not reused --- AGENTS.md | 6 +++++- CHANGELOG.md | 6 +++--- CLAUDE.md | 6 +++++- README.md | 38 +++++++++++++++++++++++++++++++++++++- lib/RuleEngine | 2 +- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76d8537..7774fbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,10 @@ addressed by a `bytes32` name. pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage its **own** namespace (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. + NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** + reused for binding — it is an `IERC3643Compliance`, which would drag in + transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) + irrelevant to a document engine. See the README rationale section. - **Events:** every write emits the standard `IERC1643` events **and** the optional `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the `smartContract` address). See `ERC-1643-proposition.md`. @@ -95,7 +99,7 @@ Other important files: ## Dependencies (tested versions) -- CMTAT `v3.3.0-rc1`, RuleEngine `v2.1.0` +- CMTAT `v3.3.0-rc1`, RuleEngine `v3.0.0-rc4` (binding-pattern reference only; compliance module not reused) - OpenZeppelin Contracts / Contracts Upgradeable `v5.6.1` - Solidity `0.8.34`, Foundry diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b9cf4..b6a4b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - **Dependencies** - Upgrade CMTAT `v2.5.0-rc0` → `v3.3.0-rc1` - Upgrade OpenZeppelin Contracts (and Contracts Upgradeable) `v5.0.2` → `v5.6.1` - - Add [CMTA/RuleEngine](https://github.com/CMTA/RuleEngine) `v2.1.0` as a submodule (binding-role reference) + - Add [CMTA/RuleEngine](https://github.com/CMTA/RuleEngine) `v3.0.0-rc4` as a submodule (binding-pattern reference; see [Why not reuse RuleEngine's compliance module?](./README.md#why-not-reuse-ruleengines-erc-3643-compliance-module) — its `ERC3643ComplianceExtendedModule` is not reused) - **Toolchain**: bump Solidity `0.8.26` → `0.8.34` and `evm_version` `cancun` → `prague` to match CMTAT v3 (CMTAT uses `require(cond, CustomError())`, which needs solc ≥ 0.8.27) - **`IERC1643` (CMTAT v3) breaking changes** - `getDocument(bytes32)` now returns a `Document` struct instead of the `(string, bytes32, uint256)` tuple. Both `getDocument` overloads updated accordingly. @@ -57,7 +57,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) ### Added -- **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. +- **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. (RuleEngine's `ERC3643ComplianceExtendedModule` was evaluated for this but intentionally not reused — see the README.) - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. - **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. - **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. @@ -67,7 +67,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) ### Changed (access control) - `DocumentEngine` now inherits **`AccessControlEnumerable`** instead of `AccessControl`, adding on-chain enumeration of role members (`getRoleMember`, `getRoleMemberCount`) and advertising `IAccessControlEnumerable` via ERC-165. Default authorization behavior is unchanged. -- Moved the role constants `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE` out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors and multi-token events. +- Moved the role constants (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors and multi-token events. ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index 76d8537..7774fbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,10 @@ addressed by a `bytes32` name. pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage its **own** namespace (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. + NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** + reused for binding — it is an `IERC3643Compliance`, which would drag in + transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) + irrelevant to a document engine. See the README rationale section. - **Events:** every write emits the standard `IERC1643` events **and** the optional `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the `smartContract` address). See `ERC-1643-proposition.md`. @@ -95,7 +99,7 @@ Other important files: ## Dependencies (tested versions) -- CMTAT `v3.3.0-rc1`, RuleEngine `v2.1.0` +- CMTAT `v3.3.0-rc1`, RuleEngine `v3.0.0-rc4` (binding-pattern reference only; compliance module not reused) - OpenZeppelin Contracts / Contracts Upgradeable `v5.6.1` - Solidity `0.8.34`, Foundry diff --git a/README.md b/README.md index 4cfbfc1..be739cc 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ function setDocument(bytes32 name_, string calldata uri_, bytes32 documentHash_) function removeDocument(bytes32 name_) external; ``` +> This mirrors the RuleEngine *binding* pattern without reusing its +> `ERC3643ComplianceExtendedModule` — see +> [Why not reuse RuleEngine's ERC-3643 compliance module?](#why-not-reuse-ruleengines-erc-3643-compliance-module) below. + ### Flexible access control Following the CMTAT / [RuleEngine](https://github.com/CMTA/RuleEngine) pattern, @@ -89,6 +93,38 @@ logic: a subclass can override a hook to change *who* is authorized (e.g. a different role, an allowlist, or open access) without touching the management functions. The default behavior is the role checks shown above. +### Why not reuse RuleEngine's ERC-3643 compliance module? + +CMTA's [RuleEngine](https://github.com/CMTA/RuleEngine) (v3) ships an +`ERC3643ComplianceExtendedModule` that offers a ready-made token-binding registry +(`bindToken` / `unbindToken` / `isTokenBound` / `getTokenBounds`). It is tempting +to reuse it for the bound-token path, but we deliberately do **not**, because that +module is an **`IERC3643Compliance`** — a *transfer-compliance* contract. + +Inheriting it would force the DocumentEngine to also implement the ERC-3643 +transfer-compliance callbacks that come with that interface: + +```solidity +function canTransfer(address, address, uint256) external view returns (bool); +function transferred(address, address, uint256) external; +function created(address, uint256) external; +function destroyed(address, uint256) external; +``` + +A document engine has **nothing to do with token transfers**, so these would have +to be stubbed as no-ops (`canTransfer` always returning `true`). That is +misleading: the contract would advertise a transfer-compliance surface it does +not honor, enlarging the ABI and inviting integrators to wire it where a real +compliance contract is expected. + +The binding concept we actually need is tiny — "is this caller a token allowed to +manage its own documents?" — so we implement just that: a `TOKEN_CONTRACT_ROLE` +in the role-based `DocumentEngine` (exactly the RuleEngine *binding* mechanism, +which is role-based, not the compliance module) and an owner-managed allowlist in +`DocumentEngineOwnable`. This keeps the engine's surface honest and minimal while +still mirroring the RuleEngine binding pattern. The RuleEngine submodule is kept +as a reference for that pattern. + ### Events On every write, the engine emits the standard `IERC1643` events **and** the @@ -227,7 +263,7 @@ The toolchain includes the following components, where the versions are the late - OpenZeppelin Contracts (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.6.1) - Tests - [CMTAT v3.3.0-rc1](https://github.com/CMTA/CMTAT/releases/tag/v3.3.0-rc1) - - [RuleEngine v2.1.0](https://github.com/CMTA/RuleEngine/releases/tag/v2.1.0) (binding-role reference) + - [RuleEngine v3.0.0-rc4](https://github.com/CMTA/RuleEngine/releases/tag/v3.0.0-rc4) (binding-pattern reference only — its compliance module is [not reused](#why-not-reuse-ruleengines-erc-3643-compliance-module)) - OpenZeppelin Contracts Upgradeable (submodule) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.6.1) ## Tools diff --git a/lib/RuleEngine b/lib/RuleEngine index 461d32f..66fcf2a 160000 --- a/lib/RuleEngine +++ b/lib/RuleEngine @@ -1 +1 @@ -Subproject commit 461d32fca6cf501d6c15f3aed5f18855b2a6581c +Subproject commit 66fcf2aafebd1f9d9de8a81dec92b88da071c9b3 From 8fcd114230cfb63433c4024f55d5ccad913f9c4b Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:54:10 +0200 Subject: [PATCH 08/19] Add multi token support --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a4b46..f725a01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) ### Added - **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. (RuleEngine's `ERC3643ComplianceExtendedModule` was evaluated for this but intentionally not reused — see the README.) -- **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./ERC-1643-proposition.md) for the proposed optional standard extension. +- **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./doc/ERCSpecification/ERC-1643-proposition.md) for the proposed optional standard extension. - **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. - **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. - **Version module implementing ERC-8303**: the version is now exposed through a dedicated `VersionModule` (`src/modules/VersionModule.sol`) implementing the `IERC8303` interface (`src/interfaces/IERC8303.sol`). It adds a standard `version()` view function (in addition to the existing public `VERSION` constant) and advertises ERC-8303 via ERC-165 (`supportsInterface(0x54fd4d50) == true`). `DocumentEngine` combines the module's `supportsInterface` with the access-control base. From 113a348d867605178a2143c27dc63eb793fba531 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:28:06 +0200 Subject: [PATCH 09/19] fix: conform DocumentEngine to updated ERC-1643 (emission responsibility, IERC1643MultiDocument, name/missing-doc guards, ERC-165) --- .gitignore | 2 + AGENTS.md | 21 ++- CHANGELOG.md | 9 ++ CLAUDE.md | 21 ++- README.md | 17 ++- doc/ERCSpecification/ERC-1643-proposition.md | 129 ------------------- doc/ERCSpecification/erc-1643.md | 64 ++++++++- src/DocumentEngine.sol | 15 ++- src/DocumentEngineBase.sol | 74 ++++++----- src/DocumentEngineInvariant.sol | 28 +--- src/DocumentEngineOwnable.sol | 15 +++ src/interfaces/IERC1643MultiDocument.sol | 57 ++++++++ test/DocumentEngine.t.sol | 47 +++++++ 13 files changed, 292 insertions(+), 207 deletions(-) delete mode 100644 doc/ERCSpecification/ERC-1643-proposition.md create mode 100644 src/interfaces/IERC1643MultiDocument.sol diff --git a/.gitignore b/.gitignore index d9e0058..810321e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ docs/ # Dotenv file .env + +history diff --git a/AGENTS.md b/AGENTS.md index 7774fbd..6d4f5ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,9 +31,15 @@ addressed by a `bytes32` name. reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) irrelevant to a document engine. See the README rationale section. -- **Events:** every write emits the standard `IERC1643` events **and** the optional - `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the - `smartContract` address). See `ERC-1643-proposition.md`. +- **Events (ERC-1643 emission responsibility):** this engine is a *shared, + multi-token* manager, so it emits **only** the address-carrying extension events + `DocumentUpdatedForSubject` / `DocumentRemovedForSubject` (param `subject`) and + **not** the base `DocumentUpdated` / `DocumentRemoved` (those carry no address and + are the token contract's responsibility). Extension declared in + `src/interfaces/IERC1643MultiDocument.sol`; rationale in `ERC-1643-proposition.md`. +- **ERC-1643 conformance:** `setDocument` reverts `ERC1643InvalidName()` on + `name == 0`; `removeDocument` reverts `ERC1643MissingDocument()` on a missing doc; + `supportsInterface` advertises `IERC1643` + `IERC1643MultiDocument` (both deployments). - **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. - **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the `hasRole` override). @@ -62,11 +68,12 @@ src/ │ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of │ # roles; owner-managed token binding (setTokenBinding) -├── DocumentEngineInvariant.sol # Shared errors + optional multi-token events only -│ # (NO access-control specifics — roles live in the -│ # role-based deployment) +├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / +│ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ -│ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +│ ├── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +│ └── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + +│ # DocumentUpdatedForSubject / DocumentRemovedForSubject) └── modules/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") diff --git a/CHANGELOG.md b/CHANGELOG.md index f725a01..452f7f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,15 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) - `DocumentEngine` now inherits **`AccessControlEnumerable`** instead of `AccessControl`, adding on-chain enumeration of role members (`getRoleMember`, `getRoleMemberCount`) and advertising `IAccessControlEnumerable` via ERC-165. Default authorization behavior is unchanged. - Moved the role constants (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors and multi-token events. +### Fixed (ERC-1643 conformance) + +Aligned the implementation with the updated [ERC-1643](./doc/ERCSpecification/erc-1643.md) (which now folds in the multi-token extension and the emission-responsibility rules): + +- **Emission responsibility.** As a shared, multi-token manager the engine now emits **only** the address-carrying extension events and **no longer** emits the base `DocumentUpdated` / `DocumentRemoved` events (the spec's `MUST NOT` for a shared manager — those events carry no `subject` and belong on the token contract). +- **Extension events/interface.** Renamed the multi-token events to the standard `DocumentUpdatedForSubject` / `DocumentRemovedForSubject` (parameter `subject`), and introduced the `IERC1643MultiDocument` interface (`src/interfaces/IERC1643MultiDocument.sol`) that the base now implements — the address-scoped `getDocument` / `getAllDocuments` / `setDocument` / `removeDocument`. +- **Input validation.** `setDocument` now reverts `ERC1643InvalidName()` when `name == bytes32(0)`; `removeDocument` now reverts `ERC1643MissingDocument()` for a non-existent document (previously it silently emitted a spurious removal event). +- **ERC-165 discovery.** `supportsInterface` now returns `true` for `type(IERC1643).interfaceId` and `type(IERC1643MultiDocument).interfaceId` (both deployments). + ### Notes / bottlenecks - CMTAT v3 no longer ships a *standalone* token that consumes an external document engine through its constructor; the standard token stores documents on-chain (`DocumentERC1643Module`). External-engine integration now goes through CMTAT's `DocumentEngineModule` (`setDocumentEngine`). The test suite was updated to exercise this real integration path via a minimal token built on `DocumentEngineModule`. diff --git a/CLAUDE.md b/CLAUDE.md index 7774fbd..6d4f5ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,9 +31,15 @@ addressed by a `bytes32` name. reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) irrelevant to a document engine. See the README rationale section. -- **Events:** every write emits the standard `IERC1643` events **and** the optional - `DocumentUpdatedForContract` / `DocumentRemovedForContract` events (which add the - `smartContract` address). See `ERC-1643-proposition.md`. +- **Events (ERC-1643 emission responsibility):** this engine is a *shared, + multi-token* manager, so it emits **only** the address-carrying extension events + `DocumentUpdatedForSubject` / `DocumentRemovedForSubject` (param `subject`) and + **not** the base `DocumentUpdated` / `DocumentRemoved` (those carry no address and + are the token contract's responsibility). Extension declared in + `src/interfaces/IERC1643MultiDocument.sol`; rationale in `ERC-1643-proposition.md`. +- **ERC-1643 conformance:** `setDocument` reverts `ERC1643InvalidName()` on + `name == 0`; `removeDocument` reverts `ERC1643MissingDocument()` on a missing doc; + `supportsInterface` advertises `IERC1643` + `IERC1643MultiDocument` (both deployments). - **ERC-2771:** meta-transaction (gasless) support; `_msgSender()` is used everywhere. - **Access control:** `DEFAULT_ADMIN_ROLE` implicitly has every role (see the `hasRole` override). @@ -62,11 +68,12 @@ src/ │ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of │ # roles; owner-managed token binding (setTokenBinding) -├── DocumentEngineInvariant.sol # Shared errors + optional multi-token events only -│ # (NO access-control specifics — roles live in the -│ # role-based deployment) +├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / +│ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ -│ └── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +│ ├── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) +│ └── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + +│ # DocumentUpdatedForSubject / DocumentRemovedForSubject) └── modules/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") diff --git a/README.md b/README.md index be739cc..3862f4c 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,17 @@ as a reference for that pattern. ### Events -On every write, the engine emits the standard `IERC1643` events **and** the -optional `DocumentUpdatedForContract` / `DocumentRemovedForContract` events, -which additionally carry the `smartContract` (token) address so off-chain -indexers can tell which contract a document belongs to during multi-contract -operations. See [ERC-1643-proposition.md](./ERC-1643-proposition.md) for the -proposed optional standard extension. +This engine is a **shared, multi-token** document manager, so — per the ERC-1643 +["Emission Responsibility"](./doc/ERCSpecification/erc-1643.md) rules — it emits +**only** the address-carrying extension events +`DocumentUpdatedForSubject(address indexed subject, …)` / +`DocumentRemovedForSubject(…)`, and **not** the base `DocumentUpdated` / +`DocumentRemoved` events. The base events carry no address and so cannot identify +which token contract a change belongs to; they are the responsibility of the +token contract that exposes ERC-1643 to consumers (it re-emits them when +delegating). See +[ERC-1643-proposition.md](./doc/ERCSpecification/ERC-1643-proposition.md) and the +`IERC1643MultiDocument` extension. ### Integration with CMTAT diff --git a/doc/ERCSpecification/ERC-1643-proposition.md b/doc/ERCSpecification/ERC-1643-proposition.md deleted file mode 100644 index e6b57df..0000000 --- a/doc/ERCSpecification/ERC-1643-proposition.md +++ /dev/null @@ -1,129 +0,0 @@ -# ERC-1643 — Proposition: optional multi-token document management - -> Status: **draft / proposition**. This document proposes optional additions to -> [ERC-1643](https://github.com/ethereum/EIPs/issues/1643) motivated by the -> `DocumentEngine` implementation in this repository. It is not part of the -> standard and is provided for discussion. - -## Context - -ERC-1643 describes document management **for a single contract**: the token that -implements the interface manages its own documents. In CMTAT v3, the standard -interface is: - -```solidity -interface IERC1643 { - struct Document { - string uri; - bytes32 documentHash; - uint256 lastModified; - } - - function getDocument(bytes32 name) external view returns (Document memory document); - function getAllDocuments() external view returns (bytes32[] memory documentNames_); - function setDocument(bytes32 name, string calldata uri, bytes32 documentHash) external; - function removeDocument(bytes32 name) external; - - event DocumentUpdated(bytes32 indexed name, string uri, bytes32 documentHash); - event DocumentRemoved(bytes32 indexed name, string uri, bytes32 documentHash); -} -``` - -A **document engine** is a different use case: a single external contract manages -documents **on behalf of several tokens**, keyed by the token address. This -reduces the code size of each token and lets one operator manage documents for a -whole fleet of tokens. - -For that multi-token use case, the standard events are **insufficient**: they only -carry the document `name`. When a single transaction sets documents for several -different tokens (batch operations), an off-chain indexer cannot tell from the -event alone which token a document belongs to — the emitting contract is always -the engine, not the token. - -## Proposition 1 — Optional multi-token events - -We propose two **optional** events that mirror the standard ones but add the -`smartContract` (token) address: - -```solidity -event DocumentUpdatedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash -); - -event DocumentRemovedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash -); -``` - -An engine that manages documents for several tokens SHOULD emit these events in -addition to the standard `DocumentUpdated` / `DocumentRemoved` events. The -standard events remain emitted for full backward compatibility with consumers -that only understand ERC-1643. - -### Important limitation: no ERC-165 discoverability - -Events are **not** part of a contract's ERC-165 interface id — `supportsInterface` -only covers function selectors. Consequently **there is no on-chain way for a -consumer to know whether a given implementation emits these optional events.** -A consumer that relies on them must obtain that information out of band (e.g. -documentation, a known implementation, or the optional extension interface below). - -## Proposition 2 — Optional multi-token management extension - -For implementations that want the multi-token capability to be -programmatically discoverable (via ERC-165) and callable, we propose an optional -extension interface. Unlike the events above, function selectors **are** part of -the ERC-165 interface id, so support can be detected on-chain. - -```solidity -interface IERC1643MultiDocument is IERC1643 { - /// @notice Get a document registered for `smartContract`. - function getDocument(address smartContract, bytes32 name) - external view returns (Document memory document); - - /// @notice Get all document names registered for `smartContract`. - function getAllDocuments(address smartContract) - external view returns (bytes32[] memory documentNames_); - - /// @notice Set or update a document for `smartContract`. - function setDocument(address smartContract, bytes32 name, string calldata uri, bytes32 documentHash) - external; - - /// @notice Remove a document for `smartContract`. - function removeDocument(address smartContract, bytes32 name) - external; - - event DocumentUpdatedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); - event DocumentRemovedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); -} -``` - -The single-argument functions inherited from `IERC1643` keep their standard -meaning: they operate on the caller (`msg.sender`) namespace, i.e. a token -managing its own documents. The address-scoped functions add the operator / -multi-token capability. - -## How this repository implements the proposition - -The `DocumentEngine` in this repository already follows Proposition 1: every -write emits both the standard event and the `...ForContract` variant. It also -provides the address-scoped functions of Proposition 2 (under -`DOCUMENT_MANAGER_ROLE`) and the single-argument, `msg.sender`-scoped functions -(under `TOKEN_CONTRACT_ROLE`, the RuleEngine binding pattern), though it does not -yet formally declare/expose an `IERC1643MultiDocument` interface id. diff --git a/doc/ERCSpecification/erc-1643.md b/doc/ERCSpecification/erc-1643.md index 20cc073..28f63a7 100644 --- a/doc/ERCSpecification/erc-1643.md +++ b/doc/ERCSpecification/erc-1643.md @@ -30,7 +30,7 @@ Historically, this proposal was authored as part of a broader security token sta ## Specification -The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", and "MAY" in this document are to be interpreted as described in RFC 2119 and RFC 8174. +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. Implementations MUST support querying and subscribing to updates on any relevant documentation for the security. @@ -82,6 +82,13 @@ Implementations SHOULD support ERC-165 interface detection. When ERC-165 is implemented, `supportsInterface` SHOULD return `true` for `type(IERC1643).interfaceId` and for the ERC-165 interface id. +`type(IERC1643MultiDocument).interfaceId` is the XOR of only the extension's own address-scoped functions; per ERC-165 semantics it does not include the base `IERC1643` selectors. Advertising the extension id therefore says nothing about whether the base single-argument functions are implemented. Accordingly: + +- A contract that implements the extension SHOULD return `true` for `type(IERC1643MultiDocument).interfaceId`. +- A contract MUST return `true` for `type(IERC1643).interfaceId` only if it also implements the base single-argument functions. A shared management contract that exposes only the address-scoped surface MUST NOT advertise `type(IERC1643).interfaceId`, since it does not implement that interface. + +Events are not part of any ERC-165 interface id, so `supportsInterface` reflects only which functions a contract implements, not whether it emits the base or extension events. + ### Function Requirements - `getDocument`: @@ -112,6 +119,58 @@ When ERC-165 is implemented, `supportsInterface` SHOULD return `true` for `type( - MUST include every document name added by `setDocument` and not removed by `removeDocument`. - MUST NOT include removed document names. +### Optional Extension: Multi-Token Document Management + +This extension is **not part of the original [ERC-1643](./eip-1643.md)**; it is an optional, additive addition for the case where document management is **delegated to a separate smart contract** that serves **more than one** token contract. + +The base `DocumentUpdated` / `DocumentRemoved` events carry no address, so consumers attribute them to the address of the contract that emitted them. This is unambiguous when a single contract both exposes ERC-1643 and stores its own documents, but breaks when one shared contract manages documents for several token contracts: the base events cannot identify which token contract a change belongs to. The extension adds address-scoped functions and address-carrying events, where `subject` is the address of the contract the documents belong to (typically a token contract, but the reasoning applies to any ERC-721/ERC-1155 token, vault, or other on-chain product). + +The extension is intentionally declared **independently of `IERC1643`** (it does not inherit it), so a shared management contract can implement the address-scoped surface **without** being forced to implement the base single-argument functions. A contract MAY implement both interfaces — for example a manager that also lets a token contract manage its own documents by calling the base functions with `msg.sender` as the implied subject — in which case it implements, and advertises, each interface separately. + +```solidity +/// @title IERC1643MultiDocument Multi-Token Document Management (optional extension) +interface IERC1643MultiDocument { + /// @notice Returns metadata for the document identified by `name` belonging to `subject`. + /// @param subject Address of the contract the documents belong to. + /// @return uri Document location. + /// @return documentHash Hash of the document contents. + /// @return lastModified Last update timestamp. + function getDocument(address subject, bytes32 name) external view returns (string memory uri, bytes32 documentHash, uint256 lastModified); + + /// @notice Creates or updates a document entry for `subject`. + /// @dev MUST emit `DocumentUpdatedForSubject` on success. + function setDocument(address subject, bytes32 name, string calldata uri, bytes32 documentHash) external; + + /// @notice Removes an existing document entry for `subject`. + /// @dev MUST emit `DocumentRemovedForSubject` on success. + function removeDocument(address subject, bytes32 name) external; + + /// @notice Returns all document names currently tracked for `subject`. + function getAllDocuments(address subject) external view returns (bytes32[] memory documentNames); + + /// @notice Emitted when a document is created or updated for `subject`. + event DocumentUpdatedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); + + /// @notice Emitted when a document is removed for `subject`. + event DocumentRemovedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); +} +``` + +#### Emission Responsibility + +The base events MUST be emitted by the contract that exposes ERC-1643 to consumers — the address consumers are expected to subscribe to. When document management is delegated, the emitter MUST be chosen so per-contract observability is preserved: + +- A management contract **dedicated to a single** token contract MAY be that token contract's ERC-1643 implementation and MUST emit the base `DocumentUpdated` / `DocumentRemoved` events; the base events are unambiguous because only one subject is served. +- A management contract **shared by multiple** token contracts MUST NOT report per-subject changes through the base events, since those events cannot identify the subject. In this configuration each token contract MUST emit the base events for its own documents, and the shared contract SHOULD emit the extension events (`DocumentUpdatedForSubject` / `DocumentRemovedForSubject`) instead, as those carry the `subject` address. +- Implementations SHOULD NOT emit the base events from **both** the token contract and the management contract. + +#### Extension Function Requirements + +- `getDocument(address subject, bytes32 name)` and `getAllDocuments(address subject)`: behave as their base counterparts, scoped to `subject`. +- `setDocument(address subject, ...)`: MUST emit `DocumentUpdatedForSubject` after state changes; SHOULD revert with `ERC1643InvalidName()` when `name == bytes32(0)`. +- `removeDocument(address subject, bytes32 name)`: MUST emit `DocumentRemovedForSubject`; SHOULD revert with `ERC1643MissingDocument()` when the named document does not exist for `subject`. +- Implementations MUST authorize writes per `subject`, so that a caller cannot create, update, or remove documents for a `subject` it is not permitted to manage. + ## Rationale The standard uses `bytes32` names to keep keys compact and deterministic, while leaving naming conventions to implementations. A URI-based pointer is used instead of on-chain document storage to avoid high gas costs and to support existing off-chain document systems. @@ -124,6 +183,8 @@ While a human-readable document title cannot always be represented directly in ` This ERC is additive and does not alter base token transfer semantics. It can be implemented alongside existing token standards and permissioning systems without changing their core behavior. +The optional multi-token extension (`IERC1643MultiDocument`) is likewise additive and was not part of the original ERC-1643. Its address-scoped functions have different signatures — hence different selectors — than the base functions, so they sit alongside them without collision, and its `DocumentUpdatedForSubject` / `DocumentRemovedForSubject` events are new topics that leave the base `DocumentUpdated` / `DocumentRemoved` events unchanged. A consumer that only knows base ERC-1643 is unaffected: it continues to call the base functions and subscribe to the base events on the address it was directed to watch. As required in the Specification, an implementation of the extension leaves the base functions and events unchanged, and every address a consumer is directed to subscribe to still emits the base events. + ## Test Cases Implementations should verify at least the following: @@ -152,6 +213,7 @@ The module maintains: - Document names may not always fit cleanly into `bytes32`, especially for long legal titles. Implementations should avoid lossy truncation of human-readable names; using a deterministic hash-based identifier (for example, the document content hash or a hash of a canonical full title) as the `bytes32` name is a safer alternative. - The custom errors `ERC1643InvalidName()` and `ERC1643MissingDocument()` are defined in this interface but were absent from the original [ERC-1643](./eip-1643.md) proposal text. Older implementations may not define these errors and may instead revert with strings or implementation-specific error patterns. Integrators should not assume all ERC-1643 contracts expose identical revert data. - ERC-165 interface detection was also not part of the earlier ERC-1643 draft text. Older implementations may not expose `supportsInterface` for ERC-165 or `IERC1643`, so integrators should treat ERC-165 support as optional when interacting with legacy deployments. +- A shared document-management contract implementing the optional multi-token extension governs documents for several subjects behind a single address. It has to authorize writes per `subject` (as required in the Specification); otherwise a caller could modify another subject's legal or operational references. Because events are not covered by ERC-165, `supportsInterface(type(IERC1643MultiDocument).interfaceId)` confirms only that the extension functions exist, not that the address-carrying events are actually emitted — integrators relying on the extension events should confirm emission out of band. ## Copyright diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index d1d16ff..b837127 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.20; import "OZ/access/extensions/AccessControlEnumerable.sol"; import {IAccessControl} from "OZ/access/IAccessControl.sol"; +import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; import "./modules/VersionModule.sol"; @@ -86,8 +88,12 @@ contract DocumentEngine is } /** - * @dev Combines the ERC-165 interface discovery of the version module - * (ERC-8303) with `AccessControlEnumerable`. See {IERC165-supportsInterface}. + * @dev ERC-165 discovery: advertises ERC-1643 and its multi-token extension, + * plus the version module (ERC-8303) and `AccessControlEnumerable`. + * The engine implements the base single-argument functions, so it advertises + * `type(IERC1643).interfaceId`; it also implements the address-scoped + * extension, so it advertises `type(IERC1643MultiDocument).interfaceId`. + * See {IERC165-supportsInterface}. */ function supportsInterface( bytes4 interfaceId @@ -98,7 +104,10 @@ contract DocumentEngine is override(VersionModule, AccessControlEnumerable) returns (bool) { - return super.supportsInterface(interfaceId); + return + interfaceId == type(IERC1643).interfaceId || + interfaceId == type(IERC1643MultiDocument).interfaceId || + super.supportsInterface(interfaceId); } /*////////////////////////////////////////////////////////////// diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol index 0e61b4c..b17cf8f 100644 --- a/src/DocumentEngineBase.sol +++ b/src/DocumentEngineBase.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.20; import "OZ/utils/Context.sol"; import "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; import "./DocumentEngineInvariant.sol"; /** @@ -20,6 +21,7 @@ import "./DocumentEngineInvariant.sol"; */ abstract contract DocumentEngineBase is IERC1643, + IERC1643MultiDocument, DocumentEngineInvariant, Context { @@ -72,22 +74,22 @@ abstract contract DocumentEngineBase is * @notice Restricted function to set or update a document */ function setDocument( - address smartContract, + address subject, bytes32 name_, string memory uri_, bytes32 documentHash_ - ) public onlyDocumentManager { - _setDocument(smartContract, name_, uri_, documentHash_); + ) public override onlyDocumentManager { + _setDocument(subject, name_, uri_, documentHash_); } /** * @notice Restricted function to remove a document for a given smart contract and name */ function removeDocument( - address smartContract, + address subject, bytes32 name_ - ) external onlyDocumentManager { - _removeDocument(smartContract, name_); + ) external override onlyDocumentManager { + _removeDocument(subject, name_); } /* ============ ERC-1643 (bound token) ============ */ @@ -208,10 +210,10 @@ abstract contract DocumentEngineBase is * @notice Public function to get a document for a specific contract address */ function getDocument( - address smartContract, + address subject, bytes32 name_ - ) external view returns (Document memory) { - return _getDocument(smartContract, name_); + ) external view override returns (Document memory) { + return _getDocument(subject, name_); } /** @@ -230,9 +232,9 @@ abstract contract DocumentEngineBase is * @notice Get all document names for a specific smart contract */ function getAllDocuments( - address smartContract - ) external view returns (bytes32[] memory) { - return _documentNames[smartContract]; + address subject + ) external view override returns (bytes32[] memory) { + return _documentNames[subject]; } /*////////////////////////////////////////////////////////////// @@ -268,44 +270,50 @@ abstract contract DocumentEngineBase is } } - function _removeDocument(address smartContract, bytes32 name_) internal { - Document memory doc = _documents[smartContract][name_]; - // Standard ERC-1643 event - emit DocumentRemoved(name_, doc.uri, doc.documentHash); - // Optional multi-token event (see ERC-1643-proposition.md) - emit DocumentRemovedForContract( - smartContract, + function _removeDocument(address subject, bytes32 name_) internal { + Document memory doc = _documents[subject][name_]; + // ERC-1643: reverts when the named document does not exist + if (doc.lastModified == 0) { + revert ERC1643MissingDocument(); + } + + // This engine is a shared, multi-subject manager: per the ERC-1643 + // "Emission Responsibility" rules it emits only the address-carrying + // extension event (the base `DocumentRemoved` is the token contract's + // responsibility). See doc/ERCSpecification. + emit DocumentRemovedForSubject( + subject, name_, doc.uri, doc.documentHash ); - delete _documents[smartContract][name_]; - _removeDocumentName(smartContract, name_); + delete _documents[subject][name_]; + _removeDocumentName(subject, name_); } function _setDocument( - address smartContract, + address subject, bytes32 name_, string memory uri_, bytes32 documentHash_ ) internal { - Document storage doc = _documents[smartContract][name_]; + // ERC-1643: reject the null name (ambiguous / default key) + if (name_ == bytes32(0)) { + revert ERC1643InvalidName(); + } + + Document storage doc = _documents[subject][name_]; if (doc.lastModified == 0) { // new document - _documentNames[smartContract].push(name_); + _documentNames[subject].push(name_); } doc.uri = uri_; doc.documentHash = documentHash_; doc.lastModified = block.timestamp; - // Standard ERC-1643 event - emit DocumentUpdated(name_, uri_, documentHash_); - // Optional multi-token event (see ERC-1643-proposition.md) - emit DocumentUpdatedForContract( - smartContract, - name_, - uri_, - documentHash_ - ); + + // Shared, multi-subject manager: emit only the address-carrying extension + // event (see {_removeDocument} note and doc/ERCSpecification). + emit DocumentUpdatedForSubject(subject, name_, uri_, documentHash_); } } diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index a554bfd..42e27a0 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -14,25 +14,11 @@ contract DocumentEngineInvariant { error InvalidInputLength(); error AdminWithAddressZeroNotAllowed(); - /** - * @notice Optional multi-token events emitted in addition to the standard - * `IERC1643.DocumentUpdated` / `IERC1643.DocumentRemoved` events. - * @dev Because this engine manages documents on behalf of several smart - * contracts (tokens), the standard events - which only carry the document - * `name` - are not sufficient to identify which contract a document belongs - * to. These events add the `smartContract` address for off-chain indexers. - * See `ERC-1643-proposition.md` for the proposed optional standard extension. - */ - event DocumentUpdatedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); - event DocumentRemovedForContract( - address indexed smartContract, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); + /// @notice Reverts when `setDocument` is called with `name == bytes32(0)`. + /// @dev ERC-1643-recommended error name. + error ERC1643InvalidName(); + + /// @notice Reverts when `removeDocument` targets a document that does not exist. + /// @dev ERC-1643-recommended error name. + error ERC1643MissingDocument(); } diff --git a/src/DocumentEngineOwnable.sol b/src/DocumentEngineOwnable.sol index 976ea3c..b221e9d 100644 --- a/src/DocumentEngineOwnable.sol +++ b/src/DocumentEngineOwnable.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.20; import {Ownable} from "OZ/access/Ownable.sol"; import {Ownable2Step} from "OZ/access/Ownable2Step.sol"; +import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; import "./modules/VersionModule.sol"; @@ -89,6 +91,19 @@ contract DocumentEngineOwnable is } } + /** + * @dev ERC-165 discovery: advertises ERC-1643 and its multi-token extension, + * plus the version module (ERC-8303). See {IERC165-supportsInterface}. + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(VersionModule) returns (bool) { + return + interfaceId == type(IERC1643).interfaceId || + interfaceId == type(IERC1643MultiDocument).interfaceId || + super.supportsInterface(interfaceId); + } + /*////////////////////////////////////////////////////////////// ERC2771 //////////////////////////////////////////////////////////////*/ diff --git a/src/interfaces/IERC1643MultiDocument.sol b/src/interfaces/IERC1643MultiDocument.sol new file mode 100644 index 0000000..2173ef4 --- /dev/null +++ b/src/interfaces/IERC1643MultiDocument.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; + +/** + * @title IERC1643MultiDocument — optional multi-token ERC-1643 extension + * @notice Address-scoped document management for a contract that manages + * documents on behalf of several `subject` contracts. + * @dev Declared **independently of `IERC1643`** (it does not inherit it), so a + * shared management contract can implement the address-scoped surface without + * being forced to implement the base single-argument functions. `subject` is the + * address of the contract the documents belong to (typically a token contract, + * but the reasoning applies to any ERC-721/ERC-1155 token, vault, or other + * on-chain product). See `doc/ERCSpecification/ERC-1643-proposition.md`. + */ +interface IERC1643MultiDocument { + /// @notice Returns metadata for the document `name` belonging to `subject`. + function getDocument( + address subject, + bytes32 name + ) external view returns (IERC1643.Document memory document); + + /// @notice Returns all document names currently tracked for `subject`. + function getAllDocuments( + address subject + ) external view returns (bytes32[] memory documentNames); + + /// @notice Creates or updates a document entry for `subject`. + /// @dev MUST emit {DocumentUpdatedForSubject} on success. + function setDocument( + address subject, + bytes32 name, + string calldata uri, + bytes32 documentHash + ) external; + + /// @notice Removes an existing document entry for `subject`. + /// @dev MUST emit {DocumentRemovedForSubject} on success. + function removeDocument(address subject, bytes32 name) external; + + /// @notice Emitted when a document is created or updated for `subject`. + event DocumentUpdatedForSubject( + address indexed subject, + bytes32 indexed name, + string uri, + bytes32 documentHash + ); + + /// @notice Emitted when a document is removed for `subject`. + event DocumentRemovedForSubject( + address indexed subject, + bytes32 indexed name, + string uri, + bytes32 documentHash + ); +} diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index fdebd21..77de7f3 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -7,6 +7,7 @@ import "../src/DocumentEngineInvariant.sol"; import "OZ/access/AccessControl.sol"; import {IERC165} from "OZ/utils/introspection/IERC165.sol"; import {IERC8303} from "../src/interfaces/IERC8303.sol"; +import {IERC1643MultiDocument} from "../src/interfaces/IERC1643MultiDocument.sol"; import {DocumentEngineModule} from "CMTAT/modules/wrapper/options/DocumentEngineModule.sol"; /** @@ -334,6 +335,52 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { ); } + function testSupportsERC1643Interfaces() public { + // implements the base single-argument functions... + assertTrue(documentEngine.supportsInterface(type(IERC1643).interfaceId)); + // ...and the address-scoped multi-token extension + assertTrue( + documentEngine.supportsInterface( + type(IERC1643MultiDocument).interfaceId + ) + ); + } + + /*////////////////////////////////////////////////////////////// + ERC-1643 input validation + //////////////////////////////////////////////////////////////*/ + + function testCannotSetDocumentWithZeroName() public { + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector(ERC1643InvalidName.selector) + ); + documentEngine.setDocument( + testContract, + bytes32(0), + documentURI, + documentHash + ); + } + + function testCannotRemoveMissingDocument() public { + vm.prank(admin); + vm.expectRevert( + abi.encodeWithSelector(ERC1643MissingDocument.selector) + ); + documentEngine.removeDocument(testContract, keccak256("does-not-exist")); + } + + function testBoundTokenCannotSetZeroName() public { + vm.prank(admin); + documentEngine.grantRole(TOKEN_CONTRACT_ROLE, testContract); + vm.prank(testContract); + vm.expectRevert( + abi.encodeWithSelector(ERC1643InvalidName.selector) + ); + documentEngine.setDocument(bytes32(0), documentURI, documentHash); + } + function testSupportsInterfaceERC165AndAccessControl() public { assertTrue( documentEngine.supportsInterface(type(IERC165).interfaceId) From 0bf09e9dd6a61f7f9fdc2e146d6980602d4a324e Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:33:02 +0200 Subject: [PATCH 10/19] feat: add Foundry deployment scripts for both DocumentEngine variants, with tests --- AGENTS.md | 10 ++- CLAUDE.md | 10 ++- README.md | 22 ++++++- script/DeployDocumentEngine.s.sol | 40 ++++++++++++ script/DeployDocumentEngineOwnable.s.sol | 43 +++++++++++++ test/Deploy.t.sol | 78 ++++++++++++++++++++++++ 6 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 script/DeployDocumentEngine.s.sol create mode 100644 script/DeployDocumentEngineOwnable.s.sol create mode 100644 test/Deploy.t.sol diff --git a/AGENTS.md b/AGENTS.md index 6d4f5ca..3176959 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,13 +78,19 @@ src/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") +script/ +├── DeployDocumentEngine.s.sol # Deploy role-based DocumentEngine (env: DOCUMENT_ENGINE_ADMIN, +│ # DOCUMENT_ENGINE_FORWARDER); run()=env, deploy(admin,fwd)=testable +└── DeployDocumentEngineOwnable.s.sol # Deploy Ownable variant (env: DOCUMENT_ENGINE_OWNER, _FORWARDER) + test/ ├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, │ # bound-token path, batch ops, ERC-8303, CMTAT integration │ # (CMTATDocumentEngineMock built on DocumentEngineModule), │ # flexible-authorization override (OpenDocumentEngine) -└── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, - # token binding, two-step ownership, ERC-8303) +├── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, +│ # token binding, two-step ownership, ERC-8303) +└── Deploy.t.sol # Tests for the deployment scripts (deploy() state + run() env) ``` **Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds diff --git a/CLAUDE.md b/CLAUDE.md index 6d4f5ca..3176959 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,13 +78,19 @@ src/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") +script/ +├── DeployDocumentEngine.s.sol # Deploy role-based DocumentEngine (env: DOCUMENT_ENGINE_ADMIN, +│ # DOCUMENT_ENGINE_FORWARDER); run()=env, deploy(admin,fwd)=testable +└── DeployDocumentEngineOwnable.s.sol # Deploy Ownable variant (env: DOCUMENT_ENGINE_OWNER, _FORWARDER) + test/ ├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, │ # bound-token path, batch ops, ERC-8303, CMTAT integration │ # (CMTATDocumentEngineMock built on DocumentEngineModule), │ # flexible-authorization override (OpenDocumentEngine) -└── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, - # token binding, two-step ownership, ERC-8303) +├── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, +│ # token binding, two-step ownership, ERC-8303) +└── Deploy.t.sol # Tests for the deployment scripts (deploy() state + run() env) ``` **Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds diff --git a/README.md b/README.md index 3862f4c..7df3c11 100644 --- a/README.md +++ b/README.md @@ -350,10 +350,30 @@ $ anvil ##### Deploy +Two deployment scripts are provided in [`script/`](./script), one per access-control +variant. Both read their configuration from environment variables: + +| Variable | Used by | Default | Meaning | +| --- | --- | --- | --- | +| `DOCUMENT_ENGINE_ADMIN` | `DeployDocumentEngine` | `msg.sender` | account granted `DEFAULT_ADMIN_ROLE` | +| `DOCUMENT_ENGINE_OWNER` | `DeployDocumentEngineOwnable` | `msg.sender` | initial owner | +| `DOCUMENT_ENGINE_FORWARDER` | both | `address(0)` | ERC-2771 trusted forwarder (`address(0)` disables gasless) | + ```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key +# Role-based DocumentEngine (AccessControlEnumerable) +$ DOCUMENT_ENGINE_ADMIN=0xYourAdmin \ + forge script script/DeployDocumentEngine.s.sol \ + --rpc-url --private-key --broadcast + +# Owner-based DocumentEngineOwnable (Ownable2Step) +$ DOCUMENT_ENGINE_OWNER=0xYourOwner \ + forge script script/DeployDocumentEngineOwnable.s.sol \ + --rpc-url --private-key --broadcast ``` +Drop `--broadcast` (and `--rpc-url`) for a local dry-run. The scripts are covered by +[`test/Deploy.t.sol`](./test/Deploy.t.sol). + ##### Cast ```shell diff --git a/script/DeployDocumentEngine.s.sol b/script/DeployDocumentEngine.s.sol new file mode 100644 index 0000000..48e983a --- /dev/null +++ b/script/DeployDocumentEngine.s.sol @@ -0,0 +1,40 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {DocumentEngine} from "../src/DocumentEngine.sol"; + +/** + * @title DeployDocumentEngine + * @notice Deploys the role-based {DocumentEngine} (AccessControlEnumerable). + * @dev Configuration via environment variables: + * - `DOCUMENT_ENGINE_ADMIN` : address granted `DEFAULT_ADMIN_ROLE` (default: `msg.sender`) + * - `DOCUMENT_ENGINE_FORWARDER` : ERC-2771 trusted forwarder, `address(0)` disables gasless (default: `address(0)`) + * + * Usage: + * forge script script/DeployDocumentEngine.s.sol \ + * --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + */ +contract DeployDocumentEngine is Script { + function run() external returns (DocumentEngine documentEngine) { + address admin = vm.envOr("DOCUMENT_ENGINE_ADMIN", msg.sender); + address forwarder = vm.envOr("DOCUMENT_ENGINE_FORWARDER", address(0)); + + documentEngine = deploy(admin, forwarder); + + console2.log("DocumentEngine deployed at:", address(documentEngine)); + console2.log(" admin :", admin); + console2.log(" trusted forwarder:", forwarder); + console2.log(" version :", documentEngine.version()); + } + + /// @dev Broadcasted deployment, isolated from env parsing so it can be reused/tested. + function deploy( + address admin, + address forwarder + ) public returns (DocumentEngine documentEngine) { + vm.startBroadcast(); + documentEngine = new DocumentEngine(admin, forwarder); + vm.stopBroadcast(); + } +} diff --git a/script/DeployDocumentEngineOwnable.s.sol b/script/DeployDocumentEngineOwnable.s.sol new file mode 100644 index 0000000..e76c813 --- /dev/null +++ b/script/DeployDocumentEngineOwnable.s.sol @@ -0,0 +1,43 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import {DocumentEngineOwnable} from "../src/DocumentEngineOwnable.sol"; + +/** + * @title DeployDocumentEngineOwnable + * @notice Deploys the owner-based {DocumentEngineOwnable} (Ownable2Step). + * @dev Configuration via environment variables: + * - `DOCUMENT_ENGINE_OWNER` : initial owner (default: `msg.sender`) + * - `DOCUMENT_ENGINE_FORWARDER` : ERC-2771 trusted forwarder, `address(0)` disables gasless (default: `address(0)`) + * + * Usage: + * forge script script/DeployDocumentEngineOwnable.s.sol \ + * --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + */ +contract DeployDocumentEngineOwnable is Script { + function run() external returns (DocumentEngineOwnable documentEngine) { + address owner = vm.envOr("DOCUMENT_ENGINE_OWNER", msg.sender); + address forwarder = vm.envOr("DOCUMENT_ENGINE_FORWARDER", address(0)); + + documentEngine = deploy(owner, forwarder); + + console2.log( + "DocumentEngineOwnable deployed at:", + address(documentEngine) + ); + console2.log(" owner :", owner); + console2.log(" trusted forwarder:", forwarder); + console2.log(" version :", documentEngine.version()); + } + + /// @dev Broadcasted deployment, isolated from env parsing so it can be reused/tested. + function deploy( + address owner, + address forwarder + ) public returns (DocumentEngineOwnable documentEngine) { + vm.startBroadcast(); + documentEngine = new DocumentEngineOwnable(owner, forwarder); + vm.stopBroadcast(); + } +} diff --git a/test/Deploy.t.sol b/test/Deploy.t.sol new file mode 100644 index 0000000..815f1cd --- /dev/null +++ b/test/Deploy.t.sol @@ -0,0 +1,78 @@ +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; +import {DeployDocumentEngine} from "../script/DeployDocumentEngine.s.sol"; +import {DeployDocumentEngineOwnable} from "../script/DeployDocumentEngineOwnable.s.sol"; +import {DocumentEngine} from "../src/DocumentEngine.sol"; +import {DocumentEngineOwnable} from "../src/DocumentEngineOwnable.sol"; + +contract DeployDocumentEngineTest is Test { + DeployDocumentEngine internal deployer; + address internal admin = makeAddr("admin"); + address internal forwarder = makeAddr("forwarder"); + + function setUp() public { + deployer = new DeployDocumentEngine(); + } + + function testDeploySetsAdminAndForwarder() public { + DocumentEngine engine = deployer.deploy(admin, forwarder); + + assertTrue(engine.hasRole(engine.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(engine.isTrustedForwarder(forwarder)); + assertEq(engine.version(), "0.4.0"); + } + + function testDeployWithoutForwarder() public { + DocumentEngine engine = deployer.deploy(admin, address(0)); + + assertTrue(engine.hasRole(engine.DEFAULT_ADMIN_ROLE(), admin)); + assertFalse(engine.isTrustedForwarder(forwarder)); + } + + function testRunReadsEnv() public { + vm.setEnv("DOCUMENT_ENGINE_ADMIN", vm.toString(admin)); + vm.setEnv("DOCUMENT_ENGINE_FORWARDER", vm.toString(forwarder)); + + DocumentEngine engine = deployer.run(); + + assertTrue(engine.hasRole(engine.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(engine.isTrustedForwarder(forwarder)); + } +} + +contract DeployDocumentEngineOwnableTest is Test { + DeployDocumentEngineOwnable internal deployer; + address internal owner = makeAddr("owner"); + address internal forwarder = makeAddr("forwarder"); + + function setUp() public { + deployer = new DeployDocumentEngineOwnable(); + } + + function testDeploySetsOwnerAndForwarder() public { + DocumentEngineOwnable engine = deployer.deploy(owner, forwarder); + + assertEq(engine.owner(), owner); + assertTrue(engine.isTrustedForwarder(forwarder)); + assertEq(engine.version(), "0.4.0"); + } + + function testDeployWithoutForwarder() public { + DocumentEngineOwnable engine = deployer.deploy(owner, address(0)); + + assertEq(engine.owner(), owner); + assertFalse(engine.isTrustedForwarder(forwarder)); + } + + function testRunReadsEnv() public { + vm.setEnv("DOCUMENT_ENGINE_OWNER", vm.toString(owner)); + vm.setEnv("DOCUMENT_ENGINE_FORWARDER", vm.toString(forwarder)); + + DocumentEngineOwnable engine = deployer.run(); + + assertEq(engine.owner(), owner); + assertTrue(engine.isTrustedForwarder(forwarder)); + } +} From 2e3e114c6ddd657af57c9bdc147dee3485d465ef Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:35:37 +0200 Subject: [PATCH 11/19] Environment Variables Don't use it for production, only for testing. See getfoundry.sh - Key Management to securely broadcasting transactions through a script --- README.md | 10 ++++++++++ script/DeployDocumentEngine.s.sol | 5 +++++ script/DeployDocumentEngineOwnable.s.sol | 5 +++++ 3 files changed, 20 insertions(+) diff --git a/README.md b/README.md index 7df3c11..ddba5bd 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,16 @@ variant. Both read their configuration from environment variables: | `DOCUMENT_ENGINE_OWNER` | `DeployDocumentEngineOwnable` | `msg.sender` | initial owner | | `DOCUMENT_ENGINE_FORWARDER` | both | `address(0)` | ERC-2771 trusted forwarder (`address(0)` disables gasless) | +> **Warning** +> +> These environment variables, and passing a raw key with `--private-key`, are +> intended for **local testing only — do not use them in production**. A private +> key supplied on the command line or through an environment variable is exposed +> in your shell history and process environment. For production deployments, use a +> secure signing method (encrypted keystore, hardware wallet, ...) as described in +> the Foundry Key Management documentation (getfoundry.sh) for securely +> broadcasting transactions through a script. + ```shell # Role-based DocumentEngine (AccessControlEnumerable) $ DOCUMENT_ENGINE_ADMIN=0xYourAdmin \ diff --git a/script/DeployDocumentEngine.s.sol b/script/DeployDocumentEngine.s.sol index 48e983a..b778daf 100644 --- a/script/DeployDocumentEngine.s.sol +++ b/script/DeployDocumentEngine.s.sol @@ -14,6 +14,11 @@ import {DocumentEngine} from "../src/DocumentEngine.sol"; * Usage: * forge script script/DeployDocumentEngine.s.sol \ * --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + * + * Warning: the environment variables above and passing a raw key with + * `--private-key` are for local testing only, not for production. For production + * use a secure signing method (encrypted keystore, hardware wallet, ...) as + * described in the Foundry Key Management documentation (getfoundry.sh). */ contract DeployDocumentEngine is Script { function run() external returns (DocumentEngine documentEngine) { diff --git a/script/DeployDocumentEngineOwnable.s.sol b/script/DeployDocumentEngineOwnable.s.sol index e76c813..5556474 100644 --- a/script/DeployDocumentEngineOwnable.s.sol +++ b/script/DeployDocumentEngineOwnable.s.sol @@ -14,6 +14,11 @@ import {DocumentEngineOwnable} from "../src/DocumentEngineOwnable.sol"; * Usage: * forge script script/DeployDocumentEngineOwnable.s.sol \ * --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + * + * Warning: the environment variables above and passing a raw key with + * `--private-key` are for local testing only, not for production. For production + * use a secure signing method (encrypted keystore, hardware wallet, ...) as + * described in the Foundry Key Management documentation (getfoundry.sh). */ contract DeployDocumentEngineOwnable is Script { function run() external returns (DocumentEngineOwnable documentEngine) { From 34ec34daf06299c56aca9f2de179d28b7267c567 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:54:06 +0200 Subject: [PATCH 12/19] refactor: unify document-manager parameter naming on subject/subjects --- src/DocumentEngineBase.sol | 44 ++++++++++++++++----------------- src/DocumentEngineInvariant.sol | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol index b17cf8f..a0be988 100644 --- a/src/DocumentEngineBase.sol +++ b/src/DocumentEngineBase.sol @@ -123,21 +123,21 @@ abstract contract DocumentEngineBase is * @notice Batch version of setDocument to handle multiple documents at once */ function batchSetDocuments( - address[] calldata smartContracts, + address[] calldata subjects, bytes32[] calldata names, string[] calldata uris, bytes32[] calldata hashes ) external onlyDocumentManager { if ( - smartContracts.length == 0 || - smartContracts.length != names.length || + subjects.length == 0 || + subjects.length != names.length || names.length != uris.length || uris.length != hashes.length ) { revert InvalidInputLength(); } - for (uint256 i = 0; i < smartContracts.length; i++) { - _setDocument(smartContracts[i], names[i], uris[i], hashes[i]); + for (uint256 i = 0; i < subjects.length; i++) { + _setDocument(subjects[i], names[i], uris[i], hashes[i]); } } @@ -145,7 +145,7 @@ abstract contract DocumentEngineBase is * @notice Batch version of setDocument to handle multiple documents at once */ function batchSetDocuments( - address smartContract, + address subject, bytes32[] calldata names, string[] calldata uris, bytes32[] calldata hashes @@ -158,7 +158,7 @@ abstract contract DocumentEngineBase is revert InvalidInputLength(); } for (uint256 i = 0; i < names.length; ++i) { - _setDocument(smartContract, names[i], uris[i], hashes[i]); + _setDocument(subject, names[i], uris[i], hashes[i]); } } @@ -166,18 +166,18 @@ abstract contract DocumentEngineBase is * @notice Batch version of removeDocument to handle multiple documents at once */ function batchRemoveDocuments( - address[] calldata smartContracts, + address[] calldata subjects, bytes32[] calldata names ) external onlyDocumentManager { if ( - smartContracts.length == 0 || - (smartContracts.length != names.length) + subjects.length == 0 || + (subjects.length != names.length) ) { revert InvalidInputLength(); } - for (uint256 i = 0; i < smartContracts.length; ++i) { - _removeDocument(smartContracts[i], names[i]); + for (uint256 i = 0; i < subjects.length; ++i) { + _removeDocument(subjects[i], names[i]); } } @@ -185,7 +185,7 @@ abstract contract DocumentEngineBase is * @notice Batch version of removeDocument to handle multiple documents at once */ function batchRemoveDocuments( - address smartContract, + address subject, bytes32[] calldata names ) external onlyDocumentManager { if (names.length == 0) { @@ -193,7 +193,7 @@ abstract contract DocumentEngineBase is } for (uint256 i = 0; i < names.length; ++i) { - _removeDocument(smartContract, names[i]); + _removeDocument(subject, names[i]); } } @@ -245,26 +245,26 @@ abstract contract DocumentEngineBase is * @dev Internal function to fetch a document */ function _getDocument( - address smartContract, + address subject, bytes32 name_ ) internal view returns (Document memory) { - return _documents[smartContract][name_]; + return _documents[subject][name_]; } /** * @dev Internal helper to remove the document name from the list of document names */ function _removeDocumentName( - address smartContract, + address subject, bytes32 name_ ) internal { - uint256 length = _documentNames[smartContract].length; + uint256 length = _documentNames[subject].length; for (uint256 i = 0; i < length; ++i) { - if (_documentNames[smartContract][i] == name_) { - _documentNames[smartContract][i] = _documentNames[ - smartContract + if (_documentNames[subject][i] == name_) { + _documentNames[subject][i] = _documentNames[ + subject ][length - 1]; - _documentNames[smartContract].pop(); + _documentNames[subject].pop(); break; } } diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index 42e27a0..ffda586 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -10,7 +10,7 @@ pragma solidity ^0.8.20; * live in {DocumentEngine}, the owner logic in {DocumentEngineOwnable}). */ contract DocumentEngineInvariant { - error DocumentNotFound(address smartContract, bytes32 name); + error DocumentNotFound(address subject, bytes32 name); error InvalidInputLength(); error AdminWithAddressZeroNotAllowed(); From a8395629d070c39d618f7e73f60c4a87b978ebc0 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:02:16 +0200 Subject: [PATCH 13/19] feat: add shared ITokenBinding surface (bindToken/unbindToken/isTokenBound) to both deployments --- AGENTS.md | 18 +++++++++------ CHANGELOG.md | 4 ++++ CLAUDE.md | 18 +++++++++------ README.md | 26 ++++++++++++++++------ src/DocumentEngine.sol | 38 +++++++++++++++++++++++++++++++- src/DocumentEngineOwnable.sol | 33 +++++++++++++++++---------- src/interfaces/ITokenBinding.sol | 26 ++++++++++++++++++++++ test/DocumentEngine.t.sol | 34 +++++++++++++++++++++++++--- test/DocumentEngineOwnable.t.sol | 36 ++++++++++++++++++++++++++---- 9 files changed, 192 insertions(+), 41 deletions(-) create mode 100644 src/interfaces/ITokenBinding.sol diff --git a/AGENTS.md b/AGENTS.md index 3176959..065f68c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,10 +23,12 @@ addressed by a `bytes32` name. - **Admin path** — `DOCUMENT_MANAGER_ROLE`. Address-scoped overloads (`setDocument(address,...)`, `removeDocument(address,...)`, batch variants) manage documents for any contract. - - **Bound-token path** — `TOKEN_CONTRACT_ROLE` (the CMTA RuleEngine binding - pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, - `removeDocument(name)`) let a bound token manage its **own** namespace - (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. + - **Bound-token path** — the standard single-arg `IERC1643` functions + (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage + its **own** namespace (`_msgSender()`). Bind via the shared `ITokenBinding` + surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` + (uniform across both deployments). Role deployment binds over + `TOKEN_CONTRACT_ROLE` (CMTA RuleEngine pattern); Ownable over an owner allowlist. NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) @@ -67,13 +69,15 @@ src/ │ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* │ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of -│ # roles; owner-managed token binding (setTokenBinding) +│ # roles; owner-managed token binding (ITokenBinding) ├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / │ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ │ ├── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) -│ └── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + -│ # DocumentUpdatedForSubject / DocumentRemovedForSubject) +│ ├── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + +│ │ # DocumentUpdatedForSubject / DocumentRemovedForSubject) +│ └── ITokenBinding.sol # Shared binding surface: bindToken / unbindToken / +│ # isTokenBound + TokenBindingSet (both deployments) └── modules/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") diff --git a/CHANGELOG.md b/CHANGELOG.md index 452f7f9..bb75132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,10 @@ Aligned the implementation with the updated [ERC-1643](./doc/ERCSpecification/er - **Input validation.** `setDocument` now reverts `ERC1643InvalidName()` when `name == bytes32(0)`; `removeDocument` now reverts `ERC1643MissingDocument()` for a non-existent document (previously it silently emitted a spurious removal event). - **ERC-165 discovery.** `supportsInterface` now returns `true` for `type(IERC1643).interfaceId` and `type(IERC1643MultiDocument).interfaceId` (both deployments). +### Added (token binding) + +- **Shared `ITokenBinding` interface** (`src/interfaces/ITokenBinding.sol`): `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` + `TokenBindingSet` event. Both deployments now implement it (and advertise `type(ITokenBinding).interfaceId` via ERC-165), so integrators bind/query a token the same way regardless of the access-control model. `DocumentEngine` implements it over `TOKEN_CONTRACT_ROLE` (grant/revoke/hasRole); `DocumentEngineOwnable` over its owner-managed allowlist (replacing the previous `setTokenBinding` / `isBoundToken`). The revert on an unbound write still differs per deployment (`AccessControlUnauthorizedAccount` vs `NotBoundToken`). + ### Notes / bottlenecks - CMTAT v3 no longer ships a *standalone* token that consumes an external document engine through its constructor; the standard token stores documents on-chain (`DocumentERC1643Module`). External-engine integration now goes through CMTAT's `DocumentEngineModule` (`setDocumentEngine`). The test suite was updated to exercise this real integration path via a minimal token built on `DocumentEngineModule`. diff --git a/CLAUDE.md b/CLAUDE.md index 3176959..065f68c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,10 +23,12 @@ addressed by a `bytes32` name. - **Admin path** — `DOCUMENT_MANAGER_ROLE`. Address-scoped overloads (`setDocument(address,...)`, `removeDocument(address,...)`, batch variants) manage documents for any contract. - - **Bound-token path** — `TOKEN_CONTRACT_ROLE` (the CMTA RuleEngine binding - pattern). The standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, - `removeDocument(name)`) let a bound token manage its **own** namespace - (`_msgSender()`). Bind a token with `grantRole(TOKEN_CONTRACT_ROLE, token)`. + - **Bound-token path** — the standard single-arg `IERC1643` functions + (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage + its **own** namespace (`_msgSender()`). Bind via the shared `ITokenBinding` + surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` + (uniform across both deployments). Role deployment binds over + `TOKEN_CONTRACT_ROLE` (CMTA RuleEngine pattern); Ownable over an owner allowlist. NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) @@ -67,13 +69,15 @@ src/ │ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* │ # impls, hasRole), ERC-2771, supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of -│ # roles; owner-managed token binding (setTokenBinding) +│ # roles; owner-managed token binding (ITokenBinding) ├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / │ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ │ ├── IERC8303.sol # ERC-8303 "Contract Version" interface (id 0x54fd4d50) -│ └── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + -│ # DocumentUpdatedForSubject / DocumentRemovedForSubject) +│ ├── IERC1643MultiDocument.sol # Multi-token ERC-1643 extension (address-scoped fns + +│ │ # DocumentUpdatedForSubject / DocumentRemovedForSubject) +│ └── ITokenBinding.sol # Shared binding surface: bindToken / unbindToken / +│ # isTokenBound + TokenBindingSet (both deployments) └── modules/ └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, # holds the VERSION constant (currently "0.4.0") diff --git a/README.md b/README.md index ddba5bd..134a6b2 100644 --- a/README.md +++ b/README.md @@ -49,15 +49,23 @@ function setDocument(address smartContract, bytes32 name_, string memory uri_, b function removeDocument(address smartContract, bytes32 name_) external; ``` -**2. Bound-token path (`TOKEN_CONTRACT_ROLE`).** This implements the standard, -single-argument ERC-1643 functions. A token is *bound* to the engine by being -granted `TOKEN_CONTRACT_ROLE` (the same binding pattern as the CMTA -[RuleEngine](https://github.com/CMTA/RuleEngine)): +**2. Bound-token path.** This implements the standard, single-argument ERC-1643 +functions. A token is *bound* to the engine through the shared **`ITokenBinding`** +surface — identical across both deployments, so integrators bind/query a token the +same way regardless of the access-control model: ```solidity -documentEngine.grantRole(TOKEN_CONTRACT_ROLE, address(token)); +documentEngine.bindToken(address(token)); // also: unbindToken(token), isTokenBound(token) ``` +Under the hood the role-based `DocumentEngine` binds by granting +`TOKEN_CONTRACT_ROLE` (the CMTA [RuleEngine](https://github.com/CMTA/RuleEngine) +pattern) and the `DocumentEngineOwnable` uses an owner-managed allowlist; both +expose the same `bindToken` / `unbindToken` / `isTokenBound` functions and the +`TokenBindingSet` event. (The revert raised when a *non-bound* caller attempts a +write differs — `AccessControlUnauthorizedAccount` vs `NotBoundToken` — since it +comes from each deployment's access-control model.) + Once bound, the token manages its **own** documents (`msg.sender` is the token); it can never affect another contract's documents: @@ -164,8 +172,12 @@ The engine is split into two contracts (CMTAT module/deployment pattern): the members of each role on-chain. - **`DocumentEngineOwnable`** (alternative deployment) — same base logic, but access control is a single **owner** via `Ownable2Step` (two-step ownership - transfer) instead of roles. Admin management is `owner`-only; the bound-token - path uses an owner-managed allowlist (`setTokenBinding` / `isBoundToken`). + transfer) instead of roles. Admin management is `owner`-only; token binding uses + an owner-managed allowlist behind the shared `ITokenBinding` surface. + +Both deployments implement the shared **`ITokenBinding`** interface +(`bindToken` / `unbindToken` / `isTokenBound` + `TokenBindingSet`), so the binding +surface is uniform and ERC-165-discoverable regardless of the access-control model. `DocumentEngineInvariant` provides the errors and the optional multi-token events shared by every deployment. Access-control specifics are **not** defined there: diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index b837127..4e94b22 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -5,6 +5,7 @@ import "OZ/access/extensions/AccessControlEnumerable.sol"; import {IAccessControl} from "OZ/access/IAccessControl.sol"; import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; +import {ITokenBinding} from "./interfaces/ITokenBinding.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; import "./modules/VersionModule.sol"; @@ -24,7 +25,8 @@ contract DocumentEngine is DocumentEngineBase, VersionModule, AccessControlEnumerable, - ERC2771Context + ERC2771Context, + ITokenBinding { // Role allowed to manage documents on behalf of any smart contract (admin path) bytes32 public constant DOCUMENT_MANAGER_ROLE = @@ -73,6 +75,39 @@ contract DocumentEngine is _checkRole(TOKEN_CONTRACT_ROLE); } + /* ============ Token binding (ITokenBinding) ============ */ + + /** + * @inheritdoc ITokenBinding + * @dev Binding a token is granting it `TOKEN_CONTRACT_ROLE`. Authorization is + * that of {AccessControl-grantRole} (the role admin of `TOKEN_CONTRACT_ROLE`, + * i.e. `DEFAULT_ADMIN_ROLE` by default). + */ + function bindToken(address token) external override { + grantRole(TOKEN_CONTRACT_ROLE, token); + emit TokenBindingSet(token, true); + } + + /** + * @inheritdoc ITokenBinding + * @dev Unbinding a token is revoking its `TOKEN_CONTRACT_ROLE`. + */ + function unbindToken(address token) external override { + revokeRole(TOKEN_CONTRACT_ROLE, token); + emit TokenBindingSet(token, false); + } + + /** + * @inheritdoc ITokenBinding + * @dev Note: because the default admin holds every role (see {hasRole}), this + * returns `true` for a `DEFAULT_ADMIN_ROLE` holder as well. + */ + function isTokenBound( + address token + ) external view override returns (bool) { + return hasRole(TOKEN_CONTRACT_ROLE, token); + } + /* * @dev Returns `true` if `account` has been granted `role`. */ @@ -107,6 +142,7 @@ contract DocumentEngine is return interfaceId == type(IERC1643).interfaceId || interfaceId == type(IERC1643MultiDocument).interfaceId || + interfaceId == type(ITokenBinding).interfaceId || super.supportsInterface(interfaceId); } diff --git a/src/DocumentEngineOwnable.sol b/src/DocumentEngineOwnable.sol index b221e9d..c70821d 100644 --- a/src/DocumentEngineOwnable.sol +++ b/src/DocumentEngineOwnable.sol @@ -5,6 +5,7 @@ import {Ownable} from "OZ/access/Ownable.sol"; import {Ownable2Step} from "OZ/access/Ownable2Step.sol"; import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; +import {ITokenBinding} from "./interfaces/ITokenBinding.sol"; import "OZ/metatx/ERC2771Context.sol"; import "./DocumentEngineBase.sol"; import "./modules/VersionModule.sol"; @@ -27,14 +28,12 @@ contract DocumentEngineOwnable is DocumentEngineBase, VersionModule, Ownable2Step, - ERC2771Context + ERC2771Context, + ITokenBinding { /// @dev Tokens bound to the engine, allowed to manage their own documents. mapping(address => bool) private _boundTokens; - /// @notice Emitted when a token binding is set or removed by the owner. - event TokenBindingSet(address indexed token, bool bound); - /// @notice Thrown when a non-bound caller uses the bound-token path. error NotBoundToken(address caller); @@ -48,20 +47,29 @@ contract DocumentEngineOwnable is ) Ownable(owner_) ERC2771Context(forwarderIrrevocable) {} /*////////////////////////////////////////////////////////////// - TOKEN BINDING + TOKEN BINDING (ITokenBinding) //////////////////////////////////////////////////////////////*/ /** - * @notice Bind or unbind a token, allowing it to manage its own documents. - * @dev Owner-managed analog of granting/revoking `TOKEN_CONTRACT_ROLE`. + * @inheritdoc ITokenBinding + * @dev Owner-managed analog of granting `TOKEN_CONTRACT_ROLE`. + */ + function bindToken(address token) external override onlyOwner { + _boundTokens[token] = true; + emit TokenBindingSet(token, true); + } + + /** + * @inheritdoc ITokenBinding + * @dev Owner-managed analog of revoking `TOKEN_CONTRACT_ROLE`. */ - function setTokenBinding(address token, bool bound) external onlyOwner { - _boundTokens[token] = bound; - emit TokenBindingSet(token, bound); + function unbindToken(address token) external override onlyOwner { + _boundTokens[token] = false; + emit TokenBindingSet(token, false); } - /// @notice Returns whether `token` is bound to the engine. - function isBoundToken(address token) external view returns (bool) { + /// @inheritdoc ITokenBinding + function isTokenBound(address token) external view override returns (bool) { return _boundTokens[token]; } @@ -101,6 +109,7 @@ contract DocumentEngineOwnable is return interfaceId == type(IERC1643).interfaceId || interfaceId == type(IERC1643MultiDocument).interfaceId || + interfaceId == type(ITokenBinding).interfaceId || super.supportsInterface(interfaceId); } diff --git a/src/interfaces/ITokenBinding.sol b/src/interfaces/ITokenBinding.sol new file mode 100644 index 0000000..e9ec45c --- /dev/null +++ b/src/interfaces/ITokenBinding.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +/** + * @title ITokenBinding + * @notice Common token-binding surface shared by every DocumentEngine deployment, + * so integrators bind, unbind and query a token the same way regardless of the + * underlying access-control model (role-based or owner-based). + * @dev A *bound* token is allowed to manage its own documents through the standard + * single-argument ERC-1643 functions (`msg.sender` is the token). Binding is a + * privileged operation; the exact authorization (a role, the owner, ...) and the + * revert raised when a non-bound caller attempts a write are deployment-specific. + */ +interface ITokenBinding { + /// @notice Emitted when a token is bound (`bound = true`) or unbound (`bound = false`). + event TokenBindingSet(address indexed token, bool bound); + + /// @notice Binds `token`, allowing it to manage its own documents. + function bindToken(address token) external; + + /// @notice Unbinds `token`. + function unbindToken(address token) external; + + /// @notice Returns whether `token` is currently bound. + function isTokenBound(address token) external view returns (bool); +} diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index 77de7f3..7a5a2fc 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -8,6 +8,7 @@ import "OZ/access/AccessControl.sol"; import {IERC165} from "OZ/utils/introspection/IERC165.sol"; import {IERC8303} from "../src/interfaces/IERC8303.sol"; import {IERC1643MultiDocument} from "../src/interfaces/IERC1643MultiDocument.sol"; +import {ITokenBinding} from "../src/interfaces/ITokenBinding.sol"; import {DocumentEngineModule} from "CMTAT/modules/wrapper/options/DocumentEngineModule.sol"; /** @@ -232,9 +233,10 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { //////////////////////////////////////////////////////////////*/ function testBoundTokenCanManageOwnDocument() public { - // Bind the token to the engine + // Bind the token to the engine (shared ITokenBinding surface) vm.prank(admin); - documentEngine.grantRole(TOKEN_CONTRACT_ROLE, testContract); + documentEngine.bindToken(testContract); + assertTrue(documentEngine.isTokenBound(testContract)); // The bound token manages its own document namespace (msg.sender) bytes32 selfName = keccak256("self-doc"); @@ -261,6 +263,28 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { assertEq(doc.lastModified, 0); } + function testNonAdminCannotBindToken() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + AccessControlUnauthorizedAccount.selector, + attacker, + DEFAULT_ADMIN_ROLE + ) + ); + documentEngine.bindToken(testContract); + } + + function testAdminCanUnbindToken() public { + vm.prank(admin); + documentEngine.bindToken(testContract); + assertTrue(documentEngine.isTokenBound(testContract)); + + vm.prank(admin); + documentEngine.unbindToken(testContract); + assertFalse(documentEngine.isTokenBound(testContract)); + } + function testUnboundContractCannotSetOwnDocument() public { bytes32 selfName = keccak256("self-doc"); vm.prank(attacker); @@ -344,6 +368,10 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { type(IERC1643MultiDocument).interfaceId ) ); + // ...and the shared token-binding surface + assertTrue( + documentEngine.supportsInterface(type(ITokenBinding).interfaceId) + ); } /*////////////////////////////////////////////////////////////// @@ -373,7 +401,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testBoundTokenCannotSetZeroName() public { vm.prank(admin); - documentEngine.grantRole(TOKEN_CONTRACT_ROLE, testContract); + documentEngine.bindToken(testContract); vm.prank(testContract); vm.expectRevert( abi.encodeWithSelector(ERC1643InvalidName.selector) diff --git a/test/DocumentEngineOwnable.t.sol b/test/DocumentEngineOwnable.t.sol index 3117c94..5d8ae80 100644 --- a/test/DocumentEngineOwnable.t.sol +++ b/test/DocumentEngineOwnable.t.sol @@ -7,6 +7,9 @@ import {Ownable} from "OZ/access/Ownable.sol"; import {IAccessControl} from "OZ/access/IAccessControl.sol"; import {IERC165} from "OZ/utils/introspection/IERC165.sol"; import {IERC8303} from "../src/interfaces/IERC8303.sol"; +import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import {IERC1643MultiDocument} from "../src/interfaces/IERC1643MultiDocument.sol"; +import {ITokenBinding} from "../src/interfaces/ITokenBinding.sol"; contract DocumentEngineOwnableTest is Test { DocumentEngineOwnable public engine; @@ -71,8 +74,8 @@ contract DocumentEngineOwnableTest is Test { function testOwnerCanBindToken() public { vm.prank(owner); - engine.setTokenBinding(testContract, true); - assertTrue(engine.isBoundToken(testContract)); + engine.bindToken(testContract); + assertTrue(engine.isTokenBound(testContract)); } function testNonOwnerCannotBindToken() public { @@ -83,12 +86,12 @@ contract DocumentEngineOwnableTest is Test { attacker ) ); - engine.setTokenBinding(testContract, true); + engine.bindToken(testContract); } function testBoundTokenCanManageOwnDocument() public { vm.prank(owner); - engine.setTokenBinding(testContract, true); + engine.bindToken(testContract); vm.prank(testContract); engine.setDocument(documentName, documentURI, documentHash); @@ -105,6 +108,26 @@ contract DocumentEngineOwnableTest is Test { assertEq(doc.lastModified, 0); } + function testUnbindTokenRevokesSelfManagement() public { + vm.prank(owner); + engine.bindToken(testContract); + assertTrue(engine.isTokenBound(testContract)); + + vm.prank(owner); + engine.unbindToken(testContract); + assertFalse(engine.isTokenBound(testContract)); + + // once unbound, the token can no longer self-manage + vm.prank(testContract); + vm.expectRevert( + abi.encodeWithSelector( + DocumentEngineOwnable.NotBoundToken.selector, + testContract + ) + ); + engine.setDocument(documentName, documentURI, documentHash); + } + function testUnboundTokenCannotSelfManage() public { vm.prank(attacker); vm.expectRevert( @@ -137,6 +160,11 @@ contract DocumentEngineOwnableTest is Test { assertEq(engine.version(), "0.4.0"); assertTrue(engine.supportsInterface(type(IERC8303).interfaceId)); assertTrue(engine.supportsInterface(type(IERC165).interfaceId)); + assertTrue(engine.supportsInterface(type(IERC1643).interfaceId)); + assertTrue( + engine.supportsInterface(type(IERC1643MultiDocument).interfaceId) + ); + assertTrue(engine.supportsInterface(type(ITokenBinding).interfaceId)); // no role-based access control here assertFalse(engine.supportsInterface(type(IAccessControl).interfaceId)); assertFalse(engine.supportsInterface(bytes4(0xffffffff))); From a77a9226089053593a291a65fd5f935a654ba32c Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:04:55 +0200 Subject: [PATCH 14/19] docs: clarify base/invariant/hasRole NatSpec and mark DocumentEngineInvariant abstract --- src/DocumentEngine.sol | 10 ++++++++-- src/DocumentEngineBase.sol | 7 ++++--- src/DocumentEngineInvariant.sol | 9 +++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 4e94b22..4dc9701 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -108,8 +108,14 @@ contract DocumentEngine is return hasRole(TOKEN_CONTRACT_ROLE, token); } - /* - * @dev Returns `true` if `account` has been granted `role`. + /** + * @dev Returns `true` if `account` has been granted `role`. The default admin + * (`DEFAULT_ADMIN_ROLE`) is treated as holding **every** role. + * + * Note: this virtual "admin has all roles" behavior is NOT reflected by + * {AccessControlEnumerable} enumeration. `getRoleMember` / `getRoleMemberCount` + * report only explicit grants, so a `DEFAULT_ADMIN_ROLE` holder satisfies + * `hasRole(anyRole, admin)` yet does not appear in `getRoleMember(anyRole, ...)`. */ function hasRole( bytes32 role, diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol index a0be988..4796066 100644 --- a/src/DocumentEngineBase.sol +++ b/src/DocumentEngineBase.sol @@ -97,9 +97,10 @@ abstract contract DocumentEngineBase is /** * @notice ERC-1643 function to set or update a document for the caller. * @dev The document is stored under the caller (`_msgSender()`) namespace. - * The caller must be a token bound to this engine (`TOKEN_CONTRACT_ROLE`), - * following the RuleEngine binding pattern. A bound token can only manage - * its own documents; it can never affect another contract's documents. + * Restricted by the `onlyBoundToken` hook: the caller must be a token bound to + * this engine. How a token is bound is deployment-specific (see the + * {_authorizeBoundTokenDocumentManagement} implementations). A bound token can + * only manage its own documents; it can never affect another contract's documents. */ function setDocument( bytes32 name_, diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index ffda586..93bfb37 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -3,13 +3,14 @@ pragma solidity ^0.8.20; /** * @title DocumentEngineInvariant - * @notice Shared errors and events for the DocumentEngine, common to every - * deployment regardless of its access-control model. + * @notice Shared errors for the DocumentEngine, common to every deployment + * regardless of its access-control model. * @dev Access-control specifics (roles, owner, ...) are intentionally NOT * defined here; they belong to the deployment contract (e.g. the role constants - * live in {DocumentEngine}, the owner logic in {DocumentEngineOwnable}). + * live in {DocumentEngine}, the owner logic in {DocumentEngineOwnable}). This + * contract is only ever used as a base, never deployed on its own. */ -contract DocumentEngineInvariant { +abstract contract DocumentEngineInvariant { error DocumentNotFound(address subject, bytes32 name); error InvalidInputLength(); error AdminWithAddressZeroNotAllowed(); From f1ae06d041b4cdbdc1a2ea5bdc838b927e8ef3db Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:23:57 +0200 Subject: [PATCH 15/19] refactor: unify token binding into shared TokenBindingModule (allowlist), drop TOKEN_CONTRACT_ROLE --- AGENTS.md | 30 ++++++------ CHANGELOG.md | 10 ++-- CLAUDE.md | 30 ++++++------ README.md | 61 ++++++++++++++---------- src/DocumentEngine.sol | 64 +++---------------------- src/DocumentEngineOwnable.sol | 73 +++++------------------------ src/modules/TokenBindingModule.sol | 75 ++++++++++++++++++++++++++++++ test/DocumentEngine.t.sol | 17 +++---- test/DocumentEngineOwnable.t.sol | 5 +- 9 files changed, 177 insertions(+), 188 deletions(-) create mode 100644 src/modules/TokenBindingModule.sol diff --git a/AGENTS.md b/AGENTS.md index 065f68c..6a86b3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,9 +26,10 @@ addressed by a `bytes32` name. - **Bound-token path** — the standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage its **own** namespace (`_msgSender()`). Bind via the shared `ITokenBinding` - surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` - (uniform across both deployments). Role deployment binds over - `TOKEN_CONTRACT_ROLE` (CMTA RuleEngine pattern); Ownable over an owner allowlist. + surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)`, + implemented **once** for both deployments by `TokenBindingModule` — a single + allowlist, NOT a role (there is no `TOKEN_CONTRACT_ROLE`). Binding is authorized + by each deployment's document-management hook (DOCUMENT_MANAGER_ROLE / owner). NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) @@ -47,10 +48,11 @@ addressed by a `bytes32` name. `hasRole` override). - **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to - overridable `internal virtual` hooks `_authorizeDocumentManagement()` / - `_authorizeBoundTokenDocumentManagement()` (default `DOCUMENT_MANAGER_ROLE` / - `TOKEN_CONTRACT_ROLE`). Keep the management implementation separate from the - authorization logic — change *who* is authorized by overriding a hook, not by + overridable `internal virtual` hooks `_authorizeDocumentManagement()` (per + deployment: `DOCUMENT_MANAGER_ROLE` / owner) and + `_authorizeBoundTokenDocumentManagement()` (implemented once by + `TokenBindingModule` → allowlist check). Keep the management implementation + separate from the authorization logic — change *who* is authorized via a hook, not by editing the management functions. - **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded @@ -65,11 +67,11 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment #1: role-based access control -│ # (AccessControlEnumerable, the role constants -│ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* -│ # impls, hasRole), ERC-2771, supportsInterface, constructor +│ # (AccessControlEnumerable, DOCUMENT_MANAGER_ROLE, +│ # _authorizeDocumentManagement, hasRole), ERC-2771, +│ # supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of -│ # roles; owner-managed token binding (ITokenBinding) +│ # roles; document mgmt + binding are owner-only ├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / │ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ @@ -79,8 +81,10 @@ src/ │ └── ITokenBinding.sol # Shared binding surface: bindToken / unbindToken / │ # isTokenBound + TokenBindingSet (both deployments) └── modules/ - └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, - # holds the VERSION constant (currently "0.4.0") + ├── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, + │ # holds the VERSION constant (currently "0.4.0") + └── TokenBindingModule.sol # Shared token-binding allowlist (ITokenBinding) + NotBoundToken; + # wires the bound-token hook; used by both deployments script/ ├── DeployDocumentEngine.s.sol # Deploy role-based DocumentEngine (env: DOCUMENT_ENGINE_ADMIN, diff --git a/CHANGELOG.md b/CHANGELOG.md index bb75132..e9205be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,17 +57,17 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) ### Added -- **Bound-token document management (RuleEngine binding pattern)**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`. They are gated by a new `TOKEN_CONTRACT_ROLE` and scoped to the caller (`_msgSender()`) own namespace. A token bound with `grantRole(TOKEN_CONTRACT_ROLE, token)` manages its own documents and can never affect another contract's documents. The existing admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. (RuleEngine's `ERC3643ComplianceExtendedModule` was evaluated for this but intentionally not reused — see the README.) +- **Bound-token document management**: implement the now-mandatory `IERC1643.setDocument(name, uri, hash)` and `removeDocument(name)`, gated by the `onlyBoundToken` modifier and scoped to the caller (`_msgSender()`) own namespace. A token bound with `bindToken(token)` (see the shared binding module below) manages its own documents and can never affect another contract's documents. The admin overloads (explicit `address`, `DOCUMENT_MANAGER_ROLE`) are unchanged, so both systems work side by side. (RuleEngine's `ERC3643ComplianceExtendedModule` was evaluated for the binding but intentionally not reused — see the README.) - **Optional multi-token events**: alongside the standard `IERC1643` events, the engine now also emits `DocumentUpdatedForContract` / `DocumentRemovedForContract`, which carry the `smartContract` (token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. See [`ERC-1643-proposition.md`](./doc/ERCSpecification/ERC-1643-proposition.md) for the proposed optional standard extension. -- **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions now use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()` (default: `DOCUMENT_MANAGER_ROLE` / `TOKEN_CONTRACT_ROLE`). This separates the document-management implementation from the authorization logic, so a subclass can change *who* is authorized without touching the management functions. Default behavior is unchanged. +- **Flexible access control (CMTAT / RuleEngine pattern)**: the restricted functions use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to overridable `internal virtual` authorization hooks `_authorizeDocumentManagement()` / `_authorizeBoundTokenDocumentManagement()`. Each deployment implements the admin hook (`DOCUMENT_MANAGER_ROLE` or `owner`); the bound-token hook is implemented once by `TokenBindingModule` (the shared allowlist). This separates the document-management implementation from the authorization logic. - **Split into a base contract and a deployment contract** (CMTAT module/deployment pattern): the document-management logic and storage now live in the new abstract `DocumentEngineBase` (with abstract `_authorize*` hooks), while `DocumentEngine` is the deployment contract that defines the access control (`AccessControl`, the concrete hooks and `hasRole`) and the ERC-2771 wiring. The deployable `DocumentEngine` API and behavior are unchanged. - **Version module implementing ERC-8303**: the version is now exposed through a dedicated `VersionModule` (`src/modules/VersionModule.sol`) implementing the `IERC8303` interface (`src/interfaces/IERC8303.sol`). It adds a standard `version()` view function (in addition to the existing public `VERSION` constant) and advertises ERC-8303 via ERC-165 (`supportsInterface(0x54fd4d50) == true`). `DocumentEngine` combines the module's `supportsInterface` with the access-control base. -- **Second deployment `DocumentEngineOwnable`** (`src/DocumentEngineOwnable.sol`): an alternative deployment that uses OpenZeppelin `Ownable2Step` (single owner, two-step transfer) instead of role-based access control, reusing the same `DocumentEngineBase` logic. Admin document management is restricted to the `owner`; the bound-token path uses an owner-managed binding allowlist (`setTokenBinding` / `isBoundToken`, the analog of `TOKEN_CONTRACT_ROLE`, reverting with `NotBoundToken`). +- **Second deployment `DocumentEngineOwnable`** (`src/DocumentEngineOwnable.sol`): an alternative deployment that uses OpenZeppelin `Ownable2Step` (single owner, two-step transfer) instead of role-based access control, reusing the same `DocumentEngineBase` logic and the shared `TokenBindingModule`. Both document management and token binding are restricted to the `owner`. ### Changed (access control) - `DocumentEngine` now inherits **`AccessControlEnumerable`** instead of `AccessControl`, adding on-chain enumeration of role members (`getRoleMember`, `getRoleMemberCount`) and advertising `IAccessControlEnumerable` via ERC-165. Default authorization behavior is unchanged. -- Moved the role constants (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors and multi-token events. +- Moved the `DOCUMENT_MANAGER_ROLE` constant out of the shared `DocumentEngineInvariant` and into the role-based `DocumentEngine`, so `DocumentEngineInvariant` (and the `DocumentEngineOwnable` deployment) no longer carry access-control-specific constants. The invariant now holds only the shared errors. ### Fixed (ERC-1643 conformance) @@ -80,7 +80,7 @@ Aligned the implementation with the updated [ERC-1643](./doc/ERCSpecification/er ### Added (token binding) -- **Shared `ITokenBinding` interface** (`src/interfaces/ITokenBinding.sol`): `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` + `TokenBindingSet` event. Both deployments now implement it (and advertise `type(ITokenBinding).interfaceId` via ERC-165), so integrators bind/query a token the same way regardless of the access-control model. `DocumentEngine` implements it over `TOKEN_CONTRACT_ROLE` (grant/revoke/hasRole); `DocumentEngineOwnable` over its owner-managed allowlist (replacing the previous `setTokenBinding` / `isBoundToken`). The revert on an unbound write still differs per deployment (`AccessControlUnauthorizedAccount` vs `NotBoundToken`). +- **Shared `ITokenBinding` interface + `TokenBindingModule`.** `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` + `TokenBindingSet` event (`src/interfaces/ITokenBinding.sol`), implemented once for both deployments by `src/modules/TokenBindingModule.sol` — a single **allowlist**, not a role. Both deployments now share the exact same binding mechanism (same functions, event, and `NotBoundToken` revert on an unbound write) and advertise `type(ITokenBinding).interfaceId` via ERC-165. The role deployment **no longer uses `TOKEN_CONTRACT_ROLE`** (removed) — binding is authorized by the document-management hook (`DOCUMENT_MANAGER_ROLE`, or the `owner` in `DocumentEngineOwnable`). ### Notes / bottlenecks diff --git a/CLAUDE.md b/CLAUDE.md index 065f68c..6a86b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,9 +26,10 @@ addressed by a `bytes32` name. - **Bound-token path** — the standard single-arg `IERC1643` functions (`setDocument(name,uri,hash)`, `removeDocument(name)`) let a bound token manage its **own** namespace (`_msgSender()`). Bind via the shared `ITokenBinding` - surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)` - (uniform across both deployments). Role deployment binds over - `TOKEN_CONTRACT_ROLE` (CMTA RuleEngine pattern); Ownable over an owner allowlist. + surface: `bindToken(token)` / `unbindToken(token)` / `isTokenBound(token)`, + implemented **once** for both deployments by `TokenBindingModule` — a single + allowlist, NOT a role (there is no `TOKEN_CONTRACT_ROLE`). Binding is authorized + by each deployment's document-management hook (DOCUMENT_MANAGER_ROLE / owner). NOTE: RuleEngine's `ERC3643ComplianceExtendedModule` is intentionally **not** reused for binding — it is an `IERC3643Compliance`, which would drag in transfer-compliance callbacks (`canTransfer`/`transferred`/`created`/`destroyed`) @@ -47,10 +48,11 @@ addressed by a `bytes32` name. `hasRole` override). - **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to - overridable `internal virtual` hooks `_authorizeDocumentManagement()` / - `_authorizeBoundTokenDocumentManagement()` (default `DOCUMENT_MANAGER_ROLE` / - `TOKEN_CONTRACT_ROLE`). Keep the management implementation separate from the - authorization logic — change *who* is authorized by overriding a hook, not by + overridable `internal virtual` hooks `_authorizeDocumentManagement()` (per + deployment: `DOCUMENT_MANAGER_ROLE` / owner) and + `_authorizeBoundTokenDocumentManagement()` (implemented once by + `TokenBindingModule` → allowlist check). Keep the management implementation + separate from the authorization logic — change *who* is authorized via a hook, not by editing the management functions. - **CMTAT integration:** since CMTAT v3, a token uses the engine via CMTAT's `DocumentEngineModule` and `setDocumentEngine(engine)` (reads/writes are forwarded @@ -65,11 +67,11 @@ src/ │ # both management paths, batch functions, modifiers, │ # and the ABSTRACT _authorize* hooks (no access control) ├── DocumentEngine.sol # Deployment #1: role-based access control -│ # (AccessControlEnumerable, the role constants -│ # DOCUMENT_MANAGER_ROLE / TOKEN_CONTRACT_ROLE, _authorize* -│ # impls, hasRole), ERC-2771, supportsInterface, constructor +│ # (AccessControlEnumerable, DOCUMENT_MANAGER_ROLE, +│ # _authorizeDocumentManagement, hasRole), ERC-2771, +│ # supportsInterface, constructor ├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of -│ # roles; owner-managed token binding (ITokenBinding) +│ # roles; document mgmt + binding are owner-only ├── DocumentEngineInvariant.sol # Shared errors only (incl. ERC1643InvalidName / │ # ERC1643MissingDocument); NO access-control specifics ├── interfaces/ @@ -79,8 +81,10 @@ src/ │ └── ITokenBinding.sol # Shared binding surface: bindToken / unbindToken / │ # isTokenBound + TokenBindingSet (both deployments) └── modules/ - └── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, - # holds the VERSION constant (currently "0.4.0") + ├── VersionModule.sol # Version module: implements ERC-8303 version() + ERC-165, + │ # holds the VERSION constant (currently "0.4.0") + └── TokenBindingModule.sol # Shared token-binding allowlist (ITokenBinding) + NotBoundToken; + # wires the bound-token hook; used by both deployments script/ ├── DeployDocumentEngine.s.sol # Deploy role-based DocumentEngine (env: DOCUMENT_ENGINE_ADMIN, diff --git a/README.md b/README.md index 134a6b2..a548d8f 100644 --- a/README.md +++ b/README.md @@ -58,13 +58,13 @@ same way regardless of the access-control model: documentEngine.bindToken(address(token)); // also: unbindToken(token), isTokenBound(token) ``` -Under the hood the role-based `DocumentEngine` binds by granting -`TOKEN_CONTRACT_ROLE` (the CMTA [RuleEngine](https://github.com/CMTA/RuleEngine) -pattern) and the `DocumentEngineOwnable` uses an owner-managed allowlist; both -expose the same `bindToken` / `unbindToken` / `isTokenBound` functions and the -`TokenBindingSet` event. (The revert raised when a *non-bound* caller attempts a -write differs — `AccessControlUnauthorizedAccount` vs `NotBoundToken` — since it -comes from each deployment's access-control model.) +Both deployments share the exact same binding mechanism — a single allowlist in +`TokenBindingModule` (`src/modules/TokenBindingModule.sol`), **not** a role. They +expose the same `bindToken` / `unbindToken` / `isTokenBound` functions, emit the +same `TokenBindingSet` event, and revert with the same `NotBoundToken` error when a +non-bound caller attempts a write. The only difference is *who* may bind: whoever +may manage documents in that deployment (the `DOCUMENT_MANAGER_ROLE` holder, or the +`owner`), since binding is authorized by the same document-management hook. Once bound, the token manages its **own** documents (`msg.sender` is the token); it can never affect another contract's documents: @@ -82,24 +82,32 @@ function removeDocument(bytes32 name_) external; ### Flexible access control Following the CMTAT / [RuleEngine](https://github.com/CMTA/RuleEngine) pattern, -the restricted functions do not hardcode a role check. They carry a **modifier** +the restricted functions do not hardcode a check. They carry a **modifier** (`onlyDocumentManager` / `onlyBoundToken`) that delegates to an **overridable `internal virtual` authorization hook**: +- the **admin path** delegates to `_authorizeDocumentManagement()`, the one hook + each deployment implements (`_checkRole(DOCUMENT_MANAGER_ROLE)` for + `DocumentEngine`, `_checkOwner()` for `DocumentEngineOwnable`); +- the **bound-token path** delegates to `_authorizeBoundTokenDocumentManagement()`, + which `TokenBindingModule` implements once for both deployments (it checks the + shared binding allowlist). + ```solidity +// implemented per deployment (the only access-control hook they supply) function _authorizeDocumentManagement() internal view virtual { - _checkRole(DOCUMENT_MANAGER_ROLE); + _checkRole(DOCUMENT_MANAGER_ROLE); // or _checkOwner() } -function _authorizeBoundTokenDocumentManagement() internal view virtual { - _checkRole(TOKEN_CONTRACT_ROLE); +// implemented once in TokenBindingModule for both deployments +function _authorizeBoundTokenDocumentManagement() internal view virtual override { + _checkTokenBound(); // reverts NotBoundToken if msg.sender is not bound } ``` This separates the document-management implementation from the authorization -logic: a subclass can override a hook to change *who* is authorized (e.g. a -different role, an allowlist, or open access) without touching the management -functions. The default behavior is the role checks shown above. +logic: a subclass changes *who* is authorized by overriding the hook, never by +touching the management functions. ### Why not reuse RuleEngine's ERC-3643 compliance module? @@ -172,17 +180,20 @@ The engine is split into two contracts (CMTAT module/deployment pattern): the members of each role on-chain. - **`DocumentEngineOwnable`** (alternative deployment) — same base logic, but access control is a single **owner** via `Ownable2Step` (two-step ownership - transfer) instead of roles. Admin management is `owner`-only; token binding uses - an owner-managed allowlist behind the shared `ITokenBinding` surface. - -Both deployments implement the shared **`ITokenBinding`** interface -(`bindToken` / `unbindToken` / `isTokenBound` + `TokenBindingSet`), so the binding -surface is uniform and ERC-165-discoverable regardless of the access-control model. - -`DocumentEngineInvariant` provides the errors and the optional multi-token events -shared by every deployment. Access-control specifics are **not** defined there: -the role constants (`DOCUMENT_MANAGER_ROLE`, `TOKEN_CONTRACT_ROLE`) live in the -role-based `DocumentEngine`, and the owner/binding logic in `DocumentEngineOwnable`. + transfer) instead of roles. Both document management and token binding are + `owner`-only. +- **`TokenBindingModule`** (`src/modules/TokenBindingModule.sol`) — the shared + token-binding registry (an allowlist) implementing `ITokenBinding` + (`bindToken` / `unbindToken` / `isTokenBound` + `TokenBindingSet`). Both + deployments inherit it, so binding is identical (same functions, event, and + `NotBoundToken` revert) and ERC-165-discoverable regardless of the + access-control model; binding is authorized by each deployment's + document-management hook. + +`DocumentEngineInvariant` provides the errors shared by every deployment. +Access-control specifics are **not** defined there: the `DOCUMENT_MANAGER_ROLE` +constant lives in the role-based `DocumentEngine`, and the owner logic in +`DocumentEngineOwnable`. `VersionModule` (`src/modules/VersionModule.sol`) isolates the version concern and implements [ERC-8303](https://ethereum-magicians.org/t/erc-8303-contract-version/28795) diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index 4dc9701..dd91bd2 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -7,7 +7,7 @@ import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; import {ITokenBinding} from "./interfaces/ITokenBinding.sol"; import "OZ/metatx/ERC2771Context.sol"; -import "./DocumentEngineBase.sol"; +import "./modules/TokenBindingModule.sol"; import "./modules/VersionModule.sol"; /** @@ -22,22 +22,17 @@ import "./modules/VersionModule.sol"; * the ERC-2771 (gasless) meta-transaction support. */ contract DocumentEngine is - DocumentEngineBase, + TokenBindingModule, VersionModule, AccessControlEnumerable, - ERC2771Context, - ITokenBinding + ERC2771Context { - // Role allowed to manage documents on behalf of any smart contract (admin path) + // Role allowed to manage documents on behalf of any smart contract, and to + // bind/unbind tokens (admin path). Token binding uses the shared allowlist in + // {TokenBindingModule}, not a dedicated role. bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); - // Role granted to a token bound to the engine, allowing it to manage its own - // documents through the standard ERC-1643 functions (msg.sender is the token). - // Mirrors the RuleEngine binding pattern (CMTA/RuleEngine `TOKEN_CONTRACT_ROLE`). - bytes32 public constant TOKEN_CONTRACT_ROLE = - keccak256("TOKEN_CONTRACT_ROLE"); - // Constructor to initialize the admin role constructor( address admin, @@ -61,53 +56,6 @@ contract DocumentEngine is _checkRole(DOCUMENT_MANAGER_ROLE); } - /** - * @dev Authorization for the bound-token document-management path. - * The caller must hold `TOKEN_CONTRACT_ROLE` (the RuleEngine binding - * pattern). Override to customize. - */ - function _authorizeBoundTokenDocumentManagement() - internal - view - virtual - override - { - _checkRole(TOKEN_CONTRACT_ROLE); - } - - /* ============ Token binding (ITokenBinding) ============ */ - - /** - * @inheritdoc ITokenBinding - * @dev Binding a token is granting it `TOKEN_CONTRACT_ROLE`. Authorization is - * that of {AccessControl-grantRole} (the role admin of `TOKEN_CONTRACT_ROLE`, - * i.e. `DEFAULT_ADMIN_ROLE` by default). - */ - function bindToken(address token) external override { - grantRole(TOKEN_CONTRACT_ROLE, token); - emit TokenBindingSet(token, true); - } - - /** - * @inheritdoc ITokenBinding - * @dev Unbinding a token is revoking its `TOKEN_CONTRACT_ROLE`. - */ - function unbindToken(address token) external override { - revokeRole(TOKEN_CONTRACT_ROLE, token); - emit TokenBindingSet(token, false); - } - - /** - * @inheritdoc ITokenBinding - * @dev Note: because the default admin holds every role (see {hasRole}), this - * returns `true` for a `DEFAULT_ADMIN_ROLE` holder as well. - */ - function isTokenBound( - address token - ) external view override returns (bool) { - return hasRole(TOKEN_CONTRACT_ROLE, token); - } - /** * @dev Returns `true` if `account` has been granted `role`. The default admin * (`DEFAULT_ADMIN_ROLE`) is treated as holding **every** role. diff --git a/src/DocumentEngineOwnable.sol b/src/DocumentEngineOwnable.sol index c70821d..b0fb598 100644 --- a/src/DocumentEngineOwnable.sol +++ b/src/DocumentEngineOwnable.sol @@ -7,36 +7,26 @@ import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import {IERC1643MultiDocument} from "./interfaces/IERC1643MultiDocument.sol"; import {ITokenBinding} from "./interfaces/ITokenBinding.sol"; import "OZ/metatx/ERC2771Context.sol"; -import "./DocumentEngineBase.sol"; +import "./modules/TokenBindingModule.sol"; import "./modules/VersionModule.sol"; /** * @title DocumentEngineOwnable * @notice Alternative deployment of the DocumentEngine that uses a single owner * ({Ownable2Step}) instead of role-based access control. - * @dev Reuses the same document-management logic ({DocumentEngineBase}) and only - * swaps the access-control implementation, illustrating the base/deployment - * separation: - * - admin path (`onlyDocumentManager`): restricted to the `owner`; - * - bound-token path (`onlyBoundToken`): restricted to tokens the owner has - * bound to the engine (owner-managed allowlist, the analog of the role-based - * `TOKEN_CONTRACT_ROLE` binding). A bound token manages only its own documents. - * Ownership uses the two-step transfer flow for safety, and the contract also - * exposes its version through ERC-8303 ({VersionModule}) and wires ERC-2771. + * @dev Reuses the same document-management logic ({DocumentEngineBase}) and token + * binding ({TokenBindingModule}), swapping only the access-control implementation: + * document management and token binding are both restricted to the `owner`, and a + * bound token manages only its own documents. Ownership uses the two-step transfer + * flow for safety, and the contract also exposes its version through ERC-8303 + * ({VersionModule}) and wires ERC-2771. */ contract DocumentEngineOwnable is - DocumentEngineBase, + TokenBindingModule, VersionModule, Ownable2Step, - ERC2771Context, - ITokenBinding + ERC2771Context { - /// @dev Tokens bound to the engine, allowed to manage their own documents. - mapping(address => bool) private _boundTokens; - - /// @notice Thrown when a non-bound caller uses the bound-token path. - error NotBoundToken(address caller); - /** * @param owner_ initial owner of the contract * @param forwarderIrrevocable address of the ERC-2771 forwarder (gasless support) @@ -46,59 +36,18 @@ contract DocumentEngineOwnable is address forwarderIrrevocable ) Ownable(owner_) ERC2771Context(forwarderIrrevocable) {} - /*////////////////////////////////////////////////////////////// - TOKEN BINDING (ITokenBinding) - //////////////////////////////////////////////////////////////*/ - - /** - * @inheritdoc ITokenBinding - * @dev Owner-managed analog of granting `TOKEN_CONTRACT_ROLE`. - */ - function bindToken(address token) external override onlyOwner { - _boundTokens[token] = true; - emit TokenBindingSet(token, true); - } - - /** - * @inheritdoc ITokenBinding - * @dev Owner-managed analog of revoking `TOKEN_CONTRACT_ROLE`. - */ - function unbindToken(address token) external override onlyOwner { - _boundTokens[token] = false; - emit TokenBindingSet(token, false); - } - - /// @inheritdoc ITokenBinding - function isTokenBound(address token) external view override returns (bool) { - return _boundTokens[token]; - } - /*////////////////////////////////////////////////////////////// ACCESS CONTROL (implementation) //////////////////////////////////////////////////////////////*/ /** - * @dev Authorization for the admin document-management path: only the owner. + * @dev Authorization for the admin document-management path (and, via + * {TokenBindingModule}, for token binding): only the owner. */ function _authorizeDocumentManagement() internal view virtual override { _checkOwner(); } - /** - * @dev Authorization for the bound-token document-management path: the - * caller must be a token bound by the owner. - */ - function _authorizeBoundTokenDocumentManagement() - internal - view - virtual - override - { - if (!_boundTokens[_msgSender()]) { - revert NotBoundToken(_msgSender()); - } - } - /** * @dev ERC-165 discovery: advertises ERC-1643 and its multi-token extension, * plus the version module (ERC-8303). See {IERC165-supportsInterface}. diff --git a/src/modules/TokenBindingModule.sol b/src/modules/TokenBindingModule.sol new file mode 100644 index 0000000..89dab0a --- /dev/null +++ b/src/modules/TokenBindingModule.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {DocumentEngineBase} from "../DocumentEngineBase.sol"; +import {ITokenBinding} from "../interfaces/ITokenBinding.sol"; + +/** + * @title TokenBindingModule + * @notice Shared token-binding registry (an allowlist) implementing {ITokenBinding}, + * used by every DocumentEngine deployment so binding behaves identically — same + * functions, same event, same revert — regardless of the access-control model. + * @dev A *bound* token may manage its own documents through the standard + * single-argument ERC-1643 functions (`msg.sender` is the token). This module: + * - stores the allowlist and implements `bindToken` / `unbindToken` / `isTokenBound`; + * - wires the base bound-token hook ({_authorizeBoundTokenDocumentManagement}) to + * the allowlist ({_checkTokenBound}); + * - gates binding management with the deployment's document-management + * authorization ({_authorizeDocumentManagement}), so whoever may manage + * documents may also decide bindings. It is therefore access-control agnostic: + * the deployment only implements {_authorizeDocumentManagement}. + */ +abstract contract TokenBindingModule is DocumentEngineBase, ITokenBinding { + /// @dev Tokens bound to the engine, allowed to manage their own documents. + mapping(address => bool) private _boundTokens; + + /// @notice Thrown when a non-bound caller attempts a bound-token operation. + error NotBoundToken(address caller); + + /** + * @inheritdoc ITokenBinding + * @dev Authorized by the deployment's document-management check. + */ + function bindToken(address token) external virtual override { + _authorizeDocumentManagement(); + _boundTokens[token] = true; + emit TokenBindingSet(token, true); + } + + /** + * @inheritdoc ITokenBinding + * @dev Authorized by the deployment's document-management check. + */ + function unbindToken(address token) external virtual override { + _authorizeDocumentManagement(); + _boundTokens[token] = false; + emit TokenBindingSet(token, false); + } + + /// @inheritdoc ITokenBinding + function isTokenBound( + address token + ) public view virtual override returns (bool) { + return _boundTokens[token]; + } + + /** + * @dev Bound-token document-management authorization: the caller + * (`_msgSender()`) must be a bound token. + */ + function _authorizeBoundTokenDocumentManagement() + internal + view + virtual + override + { + _checkTokenBound(); + } + + /// @dev Reverts {NotBoundToken} if the caller (`_msgSender()`) is not bound. + function _checkTokenBound() internal view { + if (!_boundTokens[_msgSender()]) { + revert NotBoundToken(_msgSender()); + } + } +} diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index 7a5a2fc..97e955b 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -9,6 +9,7 @@ import {IERC165} from "OZ/utils/introspection/IERC165.sol"; import {IERC8303} from "../src/interfaces/IERC8303.sol"; import {IERC1643MultiDocument} from "../src/interfaces/IERC1643MultiDocument.sol"; import {ITokenBinding} from "../src/interfaces/ITokenBinding.sol"; +import {TokenBindingModule} from "../src/modules/TokenBindingModule.sol"; import {DocumentEngineModule} from "CMTAT/modules/wrapper/options/DocumentEngineModule.sol"; /** @@ -56,8 +57,6 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // shared DocumentEngineInvariant; mirrored here for the assertions. bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); - bytes32 public constant TOKEN_CONTRACT_ROLE = - keccak256("TOKEN_CONTRACT_ROLE"); address AddressZero = address(0); function setUp() public { @@ -229,7 +228,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { } /*////////////////////////////////////////////////////////////// - Bound token (RuleEngine binding pattern, TOKEN_CONTRACT_ROLE) + Bound token (shared ITokenBinding allowlist / TokenBindingModule) //////////////////////////////////////////////////////////////*/ function testBoundTokenCanManageOwnDocument() public { @@ -269,7 +268,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { abi.encodeWithSelector( AccessControlUnauthorizedAccount.selector, attacker, - DEFAULT_ADMIN_ROLE + DOCUMENT_MANAGER_ROLE ) ); documentEngine.bindToken(testContract); @@ -290,9 +289,8 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - TOKEN_CONTRACT_ROLE + TokenBindingModule.NotBoundToken.selector, + attacker ) ); documentEngine.setDocument(selfName, documentURI, documentHash); @@ -303,9 +301,8 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - TOKEN_CONTRACT_ROLE + TokenBindingModule.NotBoundToken.selector, + attacker ) ); documentEngine.removeDocument(selfName); diff --git a/test/DocumentEngineOwnable.t.sol b/test/DocumentEngineOwnable.t.sol index 5d8ae80..ae6ea06 100644 --- a/test/DocumentEngineOwnable.t.sol +++ b/test/DocumentEngineOwnable.t.sol @@ -10,6 +10,7 @@ import {IERC8303} from "../src/interfaces/IERC8303.sol"; import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; import {IERC1643MultiDocument} from "../src/interfaces/IERC1643MultiDocument.sol"; import {ITokenBinding} from "../src/interfaces/ITokenBinding.sol"; +import {TokenBindingModule} from "../src/modules/TokenBindingModule.sol"; contract DocumentEngineOwnableTest is Test { DocumentEngineOwnable public engine; @@ -121,7 +122,7 @@ contract DocumentEngineOwnableTest is Test { vm.prank(testContract); vm.expectRevert( abi.encodeWithSelector( - DocumentEngineOwnable.NotBoundToken.selector, + TokenBindingModule.NotBoundToken.selector, testContract ) ); @@ -132,7 +133,7 @@ contract DocumentEngineOwnableTest is Test { vm.prank(attacker); vm.expectRevert( abi.encodeWithSelector( - DocumentEngineOwnable.NotBoundToken.selector, + TokenBindingModule.NotBoundToken.selector, attacker ) ); From ddc0881ea36b2ecc62916679f30cfef06e44fb60 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:34:18 +0200 Subject: [PATCH 16/19] style: adopt forge fmt as the canonical formatter (drop prettier), reformat sources --- CHANGELOG.md | 4 +- README.md | 8 +- foundry.toml | 9 + package.json | 1 - script/DeployDocumentEngine.s.sol | 5 +- script/DeployDocumentEngineOwnable.s.sol | 10 +- src/DocumentEngine.sol | 58 ++---- src/DocumentEngineBase.sol | 108 +++------- src/DocumentEngineOwnable.sol | 44 +---- src/interfaces/IERC1643MultiDocument.sol | 30 +-- src/modules/TokenBindingModule.sol | 11 +- src/modules/VersionModule.sol | 16 +- test/DocumentEngine.t.sol | 242 +++++------------------ test/DocumentEngineOwnable.t.sol | 46 +---- 14 files changed, 131 insertions(+), 461 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9205be..a6266bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,9 @@ Reference: [keepachangelog.com/en/1.1.0/](https://keepachangelog.com/en/1.1.0/) > Before a new release, perform the following tasks - Code: Update the version name in the `Version` core module, variable VERSION -- Run linter +- Run the formatter -> npm run-script lint:all:prettier +> forge fmt - Documentation - Perform a code coverage and update the files in the corresponding directory [./doc/general/test/coverage](./doc/general/test/coverage) diff --git a/README.md b/README.md index a548d8f..14801ce 100644 --- a/README.md +++ b/README.md @@ -296,10 +296,14 @@ The toolchain includes the following components, where the versions are the late ## Tools -### Prettier +### Formatting (forge fmt) + +`forge fmt` is the canonical formatter for this project (configured under `[fmt]` +in `foundry.toml`): ```bash -npx prettier --write --plugin=prettier-plugin-solidity 'src/**/*.sol' +forge fmt # format src/, test/, script/ +forge fmt --check # verify formatting (CI) ``` ### Slither diff --git a/foundry.toml b/foundry.toml index 8945d0e..67b08dd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -7,4 +7,13 @@ optimizer = true optimizer_runs = 200 evm_version = 'prague' +# `forge fmt` is the canonical formatter for this project (run `forge fmt`). +[fmt] +line_length = 120 +tab_width = 4 +bracket_spacing = false +int_types = "long" +quote_style = "double" +number_underscore = "preserve" + # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/package.json b/package.json index a56a8e3..4ca5410 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "devDependencies": { - "prettier-plugin-solidity": "^1.4.1", "solidity-docgen": "^0.6.0-beta.36", "surya": "^0.4.11" } diff --git a/script/DeployDocumentEngine.s.sol b/script/DeployDocumentEngine.s.sol index b778daf..d76e75f 100644 --- a/script/DeployDocumentEngine.s.sol +++ b/script/DeployDocumentEngine.s.sol @@ -34,10 +34,7 @@ contract DeployDocumentEngine is Script { } /// @dev Broadcasted deployment, isolated from env parsing so it can be reused/tested. - function deploy( - address admin, - address forwarder - ) public returns (DocumentEngine documentEngine) { + function deploy(address admin, address forwarder) public returns (DocumentEngine documentEngine) { vm.startBroadcast(); documentEngine = new DocumentEngine(admin, forwarder); vm.stopBroadcast(); diff --git a/script/DeployDocumentEngineOwnable.s.sol b/script/DeployDocumentEngineOwnable.s.sol index 5556474..e7b7e92 100644 --- a/script/DeployDocumentEngineOwnable.s.sol +++ b/script/DeployDocumentEngineOwnable.s.sol @@ -27,20 +27,14 @@ contract DeployDocumentEngineOwnable is Script { documentEngine = deploy(owner, forwarder); - console2.log( - "DocumentEngineOwnable deployed at:", - address(documentEngine) - ); + console2.log("DocumentEngineOwnable deployed at:", address(documentEngine)); console2.log(" owner :", owner); console2.log(" trusted forwarder:", forwarder); console2.log(" version :", documentEngine.version()); } /// @dev Broadcasted deployment, isolated from env parsing so it can be reused/tested. - function deploy( - address owner, - address forwarder - ) public returns (DocumentEngineOwnable documentEngine) { + function deploy(address owner, address forwarder) public returns (DocumentEngineOwnable documentEngine) { vm.startBroadcast(); documentEngine = new DocumentEngineOwnable(owner, forwarder); vm.stopBroadcast(); diff --git a/src/DocumentEngine.sol b/src/DocumentEngine.sol index dd91bd2..7b1a17e 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -21,23 +21,14 @@ import "./modules/VersionModule.sol"; * version is exposed through the {VersionModule} (ERC-8303), and it also wires * the ERC-2771 (gasless) meta-transaction support. */ -contract DocumentEngine is - TokenBindingModule, - VersionModule, - AccessControlEnumerable, - ERC2771Context -{ +contract DocumentEngine is TokenBindingModule, VersionModule, AccessControlEnumerable, ERC2771Context { // Role allowed to manage documents on behalf of any smart contract, and to // bind/unbind tokens (admin path). Token binding uses the shared allowlist in // {TokenBindingModule}, not a dedicated role. - bytes32 public constant DOCUMENT_MANAGER_ROLE = - keccak256("DOCUMENT_MANAGER_ROLE"); + bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); // Constructor to initialize the admin role - constructor( - address admin, - address forwarderIrrevocable - ) ERC2771Context(forwarderIrrevocable) { + constructor(address admin, address forwarderIrrevocable) ERC2771Context(forwarderIrrevocable) { if (admin == address(0)) { revert AdminWithAddressZeroNotAllowed(); } @@ -65,10 +56,13 @@ contract DocumentEngine is * report only explicit grants, so a `DEFAULT_ADMIN_ROLE` holder satisfies * `hasRole(anyRole, admin)` yet does not appear in `getRoleMember(anyRole, ...)`. */ - function hasRole( - bytes32 role, - address account - ) public view virtual override(AccessControl, IAccessControl) returns (bool) { + function hasRole(bytes32 role, address account) + public + view + virtual + override(AccessControl, IAccessControl) + returns (bool) + { // The Default Admin has all roles if (super.hasRole(DEFAULT_ADMIN_ROLE, account)) { return true; @@ -84,20 +78,15 @@ contract DocumentEngine is * extension, so it advertises `type(IERC1643MultiDocument).interfaceId`. * See {IERC165-supportsInterface}. */ - function supportsInterface( - bytes4 interfaceId - ) + function supportsInterface(bytes4 interfaceId) public view virtual override(VersionModule, AccessControlEnumerable) returns (bool) { - return - interfaceId == type(IERC1643).interfaceId || - interfaceId == type(IERC1643MultiDocument).interfaceId || - interfaceId == type(ITokenBinding).interfaceId || - super.supportsInterface(interfaceId); + return interfaceId == type(IERC1643).interfaceId || interfaceId == type(IERC1643MultiDocument).interfaceId + || interfaceId == type(ITokenBinding).interfaceId || super.supportsInterface(interfaceId); } /*////////////////////////////////////////////////////////////// @@ -107,36 +96,21 @@ contract DocumentEngine is /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgSender() - internal - view - override(ERC2771Context, Context) - returns (address sender) - { + function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) { return ERC2771Context._msgSender(); } /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgData() - internal - view - override(ERC2771Context, Context) - returns (bytes calldata) - { + function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) { return ERC2771Context._msgData(); } /** * @dev This surcharge is not necessary if you do not use the MetaTxModule */ - function _contextSuffixLength() - internal - view - override(ERC2771Context, Context) - returns (uint256) - { + function _contextSuffixLength() internal view override(ERC2771Context, Context) returns (uint256) { return ERC2771Context._contextSuffixLength(); } } diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol index 4796066..f104daf 100644 --- a/src/DocumentEngineBase.sol +++ b/src/DocumentEngineBase.sol @@ -19,12 +19,7 @@ import "./DocumentEngineInvariant.sol"; * This separation (base logic + deployment-defined access control) follows the * CMTAT and CMTA/RuleEngine pattern. */ -abstract contract DocumentEngineBase is - IERC1643, - IERC1643MultiDocument, - DocumentEngineInvariant, - Context -{ +abstract contract DocumentEngineBase is IERC1643, IERC1643MultiDocument, DocumentEngineInvariant, Context { // Mapping from contract addresses to document names to their corresponding Document structs mapping(address => mapping(bytes32 => Document)) private _documents; mapping(address => bytes32[]) private _documentNames; @@ -73,22 +68,18 @@ abstract contract DocumentEngineBase is /** * @notice Restricted function to set or update a document */ - function setDocument( - address subject, - bytes32 name_, - string memory uri_, - bytes32 documentHash_ - ) public override onlyDocumentManager { + function setDocument(address subject, bytes32 name_, string memory uri_, bytes32 documentHash_) + public + override + onlyDocumentManager + { _setDocument(subject, name_, uri_, documentHash_); } /** * @notice Restricted function to remove a document for a given smart contract and name */ - function removeDocument( - address subject, - bytes32 name_ - ) external override onlyDocumentManager { + function removeDocument(address subject, bytes32 name_) external override onlyDocumentManager { _removeDocument(subject, name_); } @@ -102,11 +93,7 @@ abstract contract DocumentEngineBase is * {_authorizeBoundTokenDocumentManagement} implementations). A bound token can * only manage its own documents; it can never affect another contract's documents. */ - function setDocument( - bytes32 name_, - string calldata uri_, - bytes32 documentHash_ - ) external override onlyBoundToken { + function setDocument(bytes32 name_, string calldata uri_, bytes32 documentHash_) external override onlyBoundToken { _setDocument(_msgSender(), name_, uri_, documentHash_); } @@ -114,9 +101,7 @@ abstract contract DocumentEngineBase is * @notice ERC-1643 function to remove a document for the caller. * @dev See {setDocument}. Scoped to the caller (`_msgSender()`) namespace. */ - function removeDocument( - bytes32 name_ - ) external override onlyBoundToken { + function removeDocument(bytes32 name_) external override onlyBoundToken { _removeDocument(_msgSender(), name_); } @@ -130,10 +115,8 @@ abstract contract DocumentEngineBase is bytes32[] calldata hashes ) external onlyDocumentManager { if ( - subjects.length == 0 || - subjects.length != names.length || - names.length != uris.length || - uris.length != hashes.length + subjects.length == 0 || subjects.length != names.length || names.length != uris.length + || uris.length != hashes.length ) { revert InvalidInputLength(); } @@ -151,11 +134,7 @@ abstract contract DocumentEngineBase is string[] calldata uris, bytes32[] calldata hashes ) external onlyDocumentManager { - if ( - names.length == 0 || - names.length != uris.length || - uris.length != hashes.length - ) { + if (names.length == 0 || names.length != uris.length || uris.length != hashes.length) { revert InvalidInputLength(); } for (uint256 i = 0; i < names.length; ++i) { @@ -166,14 +145,8 @@ abstract contract DocumentEngineBase is /** * @notice Batch version of removeDocument to handle multiple documents at once */ - function batchRemoveDocuments( - address[] calldata subjects, - bytes32[] calldata names - ) external onlyDocumentManager { - if ( - subjects.length == 0 || - (subjects.length != names.length) - ) { + function batchRemoveDocuments(address[] calldata subjects, bytes32[] calldata names) external onlyDocumentManager { + if (subjects.length == 0 || (subjects.length != names.length)) { revert InvalidInputLength(); } @@ -185,10 +158,7 @@ abstract contract DocumentEngineBase is /** * @notice Batch version of removeDocument to handle multiple documents at once */ - function batchRemoveDocuments( - address subject, - bytes32[] calldata names - ) external onlyDocumentManager { + function batchRemoveDocuments(address subject, bytes32[] calldata names) external onlyDocumentManager { if (names.length == 0) { revert InvalidInputLength(); } @@ -201,40 +171,28 @@ abstract contract DocumentEngineBase is /** * @notice ERC-1643 function to get a document for the caller (`_msgSender()`) */ - function getDocument( - bytes32 name_ - ) external view override returns (Document memory) { + function getDocument(bytes32 name_) external view override returns (Document memory) { return _getDocument(_msgSender(), name_); } /** * @notice Public function to get a document for a specific contract address */ - function getDocument( - address subject, - bytes32 name_ - ) external view override returns (Document memory) { + function getDocument(address subject, bytes32 name_) external view override returns (Document memory) { return _getDocument(subject, name_); } /** * @notice Get all document names for msg.sender */ - function getAllDocuments() - external - view - override - returns (bytes32[] memory) - { + function getAllDocuments() external view override returns (bytes32[] memory) { return _documentNames[_msgSender()]; } /** * @notice Get all document names for a specific smart contract */ - function getAllDocuments( - address subject - ) external view override returns (bytes32[] memory) { + function getAllDocuments(address subject) external view override returns (bytes32[] memory) { return _documentNames[subject]; } @@ -245,26 +203,18 @@ abstract contract DocumentEngineBase is /** * @dev Internal function to fetch a document */ - function _getDocument( - address subject, - bytes32 name_ - ) internal view returns (Document memory) { + function _getDocument(address subject, bytes32 name_) internal view returns (Document memory) { return _documents[subject][name_]; } /** * @dev Internal helper to remove the document name from the list of document names */ - function _removeDocumentName( - address subject, - bytes32 name_ - ) internal { + function _removeDocumentName(address subject, bytes32 name_) internal { uint256 length = _documentNames[subject].length; for (uint256 i = 0; i < length; ++i) { if (_documentNames[subject][i] == name_) { - _documentNames[subject][i] = _documentNames[ - subject - ][length - 1]; + _documentNames[subject][i] = _documentNames[subject][length - 1]; _documentNames[subject].pop(); break; } @@ -282,23 +232,13 @@ abstract contract DocumentEngineBase is // "Emission Responsibility" rules it emits only the address-carrying // extension event (the base `DocumentRemoved` is the token contract's // responsibility). See doc/ERCSpecification. - emit DocumentRemovedForSubject( - subject, - name_, - doc.uri, - doc.documentHash - ); + emit DocumentRemovedForSubject(subject, name_, doc.uri, doc.documentHash); delete _documents[subject][name_]; _removeDocumentName(subject, name_); } - function _setDocument( - address subject, - bytes32 name_, - string memory uri_, - bytes32 documentHash_ - ) internal { + function _setDocument(address subject, bytes32 name_, string memory uri_, bytes32 documentHash_) internal { // ERC-1643: reject the null name (ambiguous / default key) if (name_ == bytes32(0)) { revert ERC1643InvalidName(); diff --git a/src/DocumentEngineOwnable.sol b/src/DocumentEngineOwnable.sol index b0fb598..e8227cc 100644 --- a/src/DocumentEngineOwnable.sol +++ b/src/DocumentEngineOwnable.sol @@ -21,20 +21,12 @@ import "./modules/VersionModule.sol"; * flow for safety, and the contract also exposes its version through ERC-8303 * ({VersionModule}) and wires ERC-2771. */ -contract DocumentEngineOwnable is - TokenBindingModule, - VersionModule, - Ownable2Step, - ERC2771Context -{ +contract DocumentEngineOwnable is TokenBindingModule, VersionModule, Ownable2Step, ERC2771Context { /** * @param owner_ initial owner of the contract * @param forwarderIrrevocable address of the ERC-2771 forwarder (gasless support) */ - constructor( - address owner_, - address forwarderIrrevocable - ) Ownable(owner_) ERC2771Context(forwarderIrrevocable) {} + constructor(address owner_, address forwarderIrrevocable) Ownable(owner_) ERC2771Context(forwarderIrrevocable) {} /*////////////////////////////////////////////////////////////// ACCESS CONTROL (implementation) @@ -52,14 +44,9 @@ contract DocumentEngineOwnable is * @dev ERC-165 discovery: advertises ERC-1643 and its multi-token extension, * plus the version module (ERC-8303). See {IERC165-supportsInterface}. */ - function supportsInterface( - bytes4 interfaceId - ) public view virtual override(VersionModule) returns (bool) { - return - interfaceId == type(IERC1643).interfaceId || - interfaceId == type(IERC1643MultiDocument).interfaceId || - interfaceId == type(ITokenBinding).interfaceId || - super.supportsInterface(interfaceId); + function supportsInterface(bytes4 interfaceId) public view virtual override(VersionModule) returns (bool) { + return interfaceId == type(IERC1643).interfaceId || interfaceId == type(IERC1643MultiDocument).interfaceId + || interfaceId == type(ITokenBinding).interfaceId || super.supportsInterface(interfaceId); } /*////////////////////////////////////////////////////////////// @@ -69,36 +56,21 @@ contract DocumentEngineOwnable is /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgSender() - internal - view - override(ERC2771Context, Context) - returns (address sender) - { + function _msgSender() internal view override(ERC2771Context, Context) returns (address sender) { return ERC2771Context._msgSender(); } /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgData() - internal - view - override(ERC2771Context, Context) - returns (bytes calldata) - { + function _msgData() internal view override(ERC2771Context, Context) returns (bytes calldata) { return ERC2771Context._msgData(); } /** * @dev This surcharge is not necessary if you do not use the MetaTxModule */ - function _contextSuffixLength() - internal - view - override(ERC2771Context, Context) - returns (uint256) - { + function _contextSuffixLength() internal view override(ERC2771Context, Context) returns (uint256) { return ERC2771Context._contextSuffixLength(); } } diff --git a/src/interfaces/IERC1643MultiDocument.sol b/src/interfaces/IERC1643MultiDocument.sol index 2173ef4..f0dbd42 100644 --- a/src/interfaces/IERC1643MultiDocument.sol +++ b/src/interfaces/IERC1643MultiDocument.sol @@ -16,42 +16,22 @@ import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; */ interface IERC1643MultiDocument { /// @notice Returns metadata for the document `name` belonging to `subject`. - function getDocument( - address subject, - bytes32 name - ) external view returns (IERC1643.Document memory document); + function getDocument(address subject, bytes32 name) external view returns (IERC1643.Document memory document); /// @notice Returns all document names currently tracked for `subject`. - function getAllDocuments( - address subject - ) external view returns (bytes32[] memory documentNames); + function getAllDocuments(address subject) external view returns (bytes32[] memory documentNames); /// @notice Creates or updates a document entry for `subject`. /// @dev MUST emit {DocumentUpdatedForSubject} on success. - function setDocument( - address subject, - bytes32 name, - string calldata uri, - bytes32 documentHash - ) external; + function setDocument(address subject, bytes32 name, string calldata uri, bytes32 documentHash) external; /// @notice Removes an existing document entry for `subject`. /// @dev MUST emit {DocumentRemovedForSubject} on success. function removeDocument(address subject, bytes32 name) external; /// @notice Emitted when a document is created or updated for `subject`. - event DocumentUpdatedForSubject( - address indexed subject, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); + event DocumentUpdatedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); /// @notice Emitted when a document is removed for `subject`. - event DocumentRemovedForSubject( - address indexed subject, - bytes32 indexed name, - string uri, - bytes32 documentHash - ); + event DocumentRemovedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); } diff --git a/src/modules/TokenBindingModule.sol b/src/modules/TokenBindingModule.sol index 89dab0a..245fddc 100644 --- a/src/modules/TokenBindingModule.sol +++ b/src/modules/TokenBindingModule.sol @@ -47,9 +47,7 @@ abstract contract TokenBindingModule is DocumentEngineBase, ITokenBinding { } /// @inheritdoc ITokenBinding - function isTokenBound( - address token - ) public view virtual override returns (bool) { + function isTokenBound(address token) public view virtual override returns (bool) { return _boundTokens[token]; } @@ -57,12 +55,7 @@ abstract contract TokenBindingModule is DocumentEngineBase, ITokenBinding { * @dev Bound-token document-management authorization: the caller * (`_msgSender()`) must be a bound token. */ - function _authorizeBoundTokenDocumentManagement() - internal - view - virtual - override - { + function _authorizeBoundTokenDocumentManagement() internal view virtual override { _checkTokenBound(); } diff --git a/src/modules/VersionModule.sol b/src/modules/VersionModule.sol index 86ce656..e0957f6 100644 --- a/src/modules/VersionModule.sol +++ b/src/modules/VersionModule.sol @@ -23,13 +23,7 @@ abstract contract VersionModule is IERC8303, ERC165 { /** * @inheritdoc IERC8303 */ - function version() - public - view - virtual - override(IERC8303) - returns (string memory version_) - { + function version() public view virtual override(IERC8303) returns (string memory version_) { return VERSION; } @@ -37,11 +31,7 @@ abstract contract VersionModule is IERC8303, ERC165 { * @dev Advertises ERC-8303 support (interface id `0x54fd4d50`). * See {IERC165-supportsInterface}. */ - function supportsInterface( - bytes4 interfaceId - ) public view virtual override returns (bool) { - return - interfaceId == type(IERC8303).interfaceId || - super.supportsInterface(interfaceId); + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IERC8303).interfaceId || super.supportsInterface(interfaceId); } } diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index 97e955b..b05af5b 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -32,10 +32,7 @@ contract CMTATDocumentEngineMock is DocumentEngineModule { * document-management implementation. */ contract OpenDocumentEngine is DocumentEngine { - constructor( - address admin, - address forwarder - ) DocumentEngine(admin, forwarder) {} + constructor(address admin, address forwarder) DocumentEngine(admin, forwarder) {} function _authorizeDocumentManagement() internal view override { // no access restriction (custom authorization) @@ -55,19 +52,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { bytes32 public constant DOCUMENT_ROLE = keccak256("DOCUMENT_ROLE"); // Roles are defined on the role-based deployment (DocumentEngine), not on the // shared DocumentEngineInvariant; mirrored here for the assertions. - bytes32 public constant DOCUMENT_MANAGER_ROLE = - keccak256("DOCUMENT_MANAGER_ROLE"); + bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); address AddressZero = address(0); function setUp() public { documentEngine = new DocumentEngine(admin, AddressZero); vm.prank(admin); - documentEngine.setDocument( - testContract, - documentName, - documentURI, - documentHash - ); + documentEngine.setDocument(testContract, documentName, documentURI, documentHash); } /*////////////////////////////////////////////////////////////// @@ -81,9 +72,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Forwarder assertEq(documentEngine.isTrustedForwarder(forwarder), true); // admin - vm.expectRevert( - abi.encodeWithSelector(AdminWithAddressZeroNotAllowed.selector) - ); + vm.expectRevert(abi.encodeWithSelector(AdminWithAddressZeroNotAllowed.selector)); documentEngine = new DocumentEngine(AddressZero, forwarder); } @@ -94,28 +83,15 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testCannotNonAdminSetDocument() public { vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) - ); - documentEngine.setDocument( - testContract, - documentName, - documentURI, - documentHash + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); + documentEngine.setDocument(testContract, documentName, documentURI, documentHash); } function testCannotNonAdminRemoveDocument() public { vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.removeDocument(testContract, documentName); } @@ -139,21 +115,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.batchSetDocuments(smartContracts, names, uris, hashes); vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.batchSetDocuments(testContract, names, uris, hashes); } @@ -169,21 +137,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.batchRemoveDocuments(smartContracts, names); vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.batchRemoveDocuments(testContract, names); } @@ -209,12 +169,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { uint256 lastModif = block.timestamp; vm.prank(admin); - documentEngine.setDocument( - address(cmtat), - documentName, - documentURI, - documentHash - ); + documentEngine.setDocument(address(cmtat), documentName, documentURI, documentHash); // Call from CMTAT, forwarded to the engine bytes32[] memory docs = cmtat.getAllDocuments(); @@ -245,10 +200,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(testContract); documentEngine.setDocument(selfName, selfURI, selfHash); - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - selfName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, selfName); assertEq(doc.uri, selfURI); assertEq(doc.documentHash, selfHash); assertEq(doc.lastModified, block.timestamp); @@ -265,11 +217,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testNonAdminCannotBindToken() public { vm.prank(attacker); vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DOCUMENT_MANAGER_ROLE - ) + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_ROLE) ); documentEngine.bindToken(testContract); } @@ -287,24 +235,14 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testUnboundContractCannotSetOwnDocument() public { bytes32 selfName = keccak256("self-doc"); vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - TokenBindingModule.NotBoundToken.selector, - attacker - ) - ); + vm.expectRevert(abi.encodeWithSelector(TokenBindingModule.NotBoundToken.selector, attacker)); documentEngine.setDocument(selfName, documentURI, documentHash); } function testUnboundContractCannotRemoveOwnDocument() public { bytes32 selfName = keccak256("self-doc"); vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - TokenBindingModule.NotBoundToken.selector, - attacker - ) - ); + vm.expectRevert(abi.encodeWithSelector(TokenBindingModule.NotBoundToken.selector, attacker)); documentEngine.removeDocument(selfName); } @@ -313,25 +251,14 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { //////////////////////////////////////////////////////////////*/ function testFlexibleAuthorizationCanBeOverridden() public { - OpenDocumentEngine openEngine = new OpenDocumentEngine( - admin, - AddressZero - ); + OpenDocumentEngine openEngine = new OpenDocumentEngine(admin, AddressZero); // attacker holds no role, yet can manage documents because the // authorization hook was overridden to allow anyone. vm.prank(attacker); - openEngine.setDocument( - testContract, - documentName, - documentURI, - documentHash - ); + openEngine.setDocument(testContract, documentName, documentURI, documentHash); - IERC1643.Document memory doc = openEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = openEngine.getDocument(testContract, documentName); assertEq(doc.uri, documentURI); assertEq(doc.documentHash, documentHash); } @@ -351,24 +278,16 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testSupportsInterfaceERC8303() public { // interface id declared by ERC-8303 assertEq(type(IERC8303).interfaceId, bytes4(0x54fd4d50)); - assertTrue( - documentEngine.supportsInterface(type(IERC8303).interfaceId) - ); + assertTrue(documentEngine.supportsInterface(type(IERC8303).interfaceId)); } function testSupportsERC1643Interfaces() public { // implements the base single-argument functions... assertTrue(documentEngine.supportsInterface(type(IERC1643).interfaceId)); // ...and the address-scoped multi-token extension - assertTrue( - documentEngine.supportsInterface( - type(IERC1643MultiDocument).interfaceId - ) - ); + assertTrue(documentEngine.supportsInterface(type(IERC1643MultiDocument).interfaceId)); // ...and the shared token-binding surface - assertTrue( - documentEngine.supportsInterface(type(ITokenBinding).interfaceId) - ); + assertTrue(documentEngine.supportsInterface(type(ITokenBinding).interfaceId)); } /*////////////////////////////////////////////////////////////// @@ -377,22 +296,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testCannotSetDocumentWithZeroName() public { vm.prank(admin); - vm.expectRevert( - abi.encodeWithSelector(ERC1643InvalidName.selector) - ); - documentEngine.setDocument( - testContract, - bytes32(0), - documentURI, - documentHash - ); + vm.expectRevert(abi.encodeWithSelector(ERC1643InvalidName.selector)); + documentEngine.setDocument(testContract, bytes32(0), documentURI, documentHash); } function testCannotRemoveMissingDocument() public { vm.prank(admin); - vm.expectRevert( - abi.encodeWithSelector(ERC1643MissingDocument.selector) - ); + vm.expectRevert(abi.encodeWithSelector(ERC1643MissingDocument.selector)); documentEngine.removeDocument(testContract, keccak256("does-not-exist")); } @@ -400,19 +310,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(admin); documentEngine.bindToken(testContract); vm.prank(testContract); - vm.expectRevert( - abi.encodeWithSelector(ERC1643InvalidName.selector) - ); + vm.expectRevert(abi.encodeWithSelector(ERC1643InvalidName.selector)); documentEngine.setDocument(bytes32(0), documentURI, documentHash); } function testSupportsInterfaceERC165AndAccessControl() public { - assertTrue( - documentEngine.supportsInterface(type(IERC165).interfaceId) - ); - assertTrue( - documentEngine.supportsInterface(type(IAccessControl).interfaceId) - ); + assertTrue(documentEngine.supportsInterface(type(IERC165).interfaceId)); + assertTrue(documentEngine.supportsInterface(type(IAccessControl).interfaceId)); } function testDoesNotSupportInvalidInterface() public { @@ -425,17 +329,9 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testAdminCanSetDocument() public { uint256 lastModif = block.timestamp; vm.prank(admin); - documentEngine.setDocument( - testContract, - documentName, - documentURI, - documentHash - ); + documentEngine.setDocument(testContract, documentName, documentURI, documentHash); - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, documentName); assertEq(doc.uri, documentURI); assertEq(doc.documentHash, documentHash); assertEq(doc.lastModified, lastModif); @@ -444,12 +340,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { function testAdminCanSetDocumentAgain() public { // Arrange vm.prank(admin); - documentEngine.setDocument( - testContract, - documentName, - documentURI, - documentHash - ); + documentEngine.setDocument(testContract, documentName, documentURI, documentHash); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 1); assertEq(docs[0], documentName); @@ -458,18 +349,10 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { string memory documentURIV2 = "https://example.com/doc1"; bytes32 documentHashV2 = keccak256("doc1Hash"); vm.prank(admin); - documentEngine.setDocument( - testContract, - documentName, - documentURIV2, - documentHashV2 - ); + documentEngine.setDocument(testContract, documentName, documentURIV2, documentHashV2); // Assert - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, documentName); assertEq(doc.uri, documentURIV2); assertEq(doc.documentHash, documentHashV2); assertEq(doc.lastModified, lastModif); @@ -499,19 +382,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(smartContracts, names, uris, hashes); // Check the first document - IERC1643.Document memory doc1 = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc1 = documentEngine.getDocument(testContract, documentName); assertEq(doc1.uri, documentURI); assertEq(doc1.documentHash, documentHash); assertEq(doc1.lastModified, block.timestamp); // Check the second document - IERC1643.Document memory doc2 = documentEngine.getDocument( - anotherSmartContract, - names[1] - ); + IERC1643.Document memory doc2 = documentEngine.getDocument(anotherSmartContract, names[1]); assertEq(doc2.uri, uris[1]); assertEq(doc2.documentHash, hashes[1]); assertEq(doc2.lastModified, block.timestamp); @@ -538,19 +415,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(smartContracts, names, uris, hashes); // Check the first document - IERC1643.Document memory doc1 = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc1 = documentEngine.getDocument(testContract, documentName); assertEq(doc1.uri, documentURI); assertEq(doc1.documentHash, documentHash); assertEq(doc1.lastModified, block.timestamp); // Check the second document - IERC1643.Document memory doc2 = documentEngine.getDocument( - testContract, - names[1] - ); + IERC1643.Document memory doc2 = documentEngine.getDocument(testContract, names[1]); assertEq(doc2.uri, uris[1]); assertEq(doc2.documentHash, hashes[1]); assertEq(doc2.lastModified, block.timestamp); @@ -620,10 +491,7 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, documentName); assertEq(doc.uri, ""); assertEq(doc.documentHash, ""); assertEq(doc.lastModified, 0); @@ -649,20 +517,14 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, documentName); assertEq(doc.uri, ""); assertEq(doc.documentHash, ""); assertEq(doc.lastModified, 0); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 0); - IERC1643.Document memory doc2 = documentEngine.getDocument( - anotherSmartContract, - names[1] - ); + IERC1643.Document memory doc2 = documentEngine.getDocument(anotherSmartContract, names[1]); assertEq(doc2.uri, ""); assertEq(doc2.documentHash, ""); assertEq(doc2.lastModified, 0); @@ -711,19 +573,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { documentEngine.batchSetDocuments(testContract, names, uris, hashes); // Check the first document - IERC1643.Document memory doc1 = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc1 = documentEngine.getDocument(testContract, documentName); assertEq(doc1.uri, documentURI); assertEq(doc1.documentHash, documentHash); assertEq(doc1.lastModified, block.timestamp); // Check the second document - IERC1643.Document memory doc2 = documentEngine.getDocument( - testContract, - names[1] - ); + IERC1643.Document memory doc2 = documentEngine.getDocument(testContract, names[1]); assertEq(doc2.uri, uris[1]); assertEq(doc2.documentHash, hashes[1]); assertEq(doc2.lastModified, block.timestamp); @@ -743,27 +599,19 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { // Check that both documents are removed // Check the second document - IERC1643.Document memory doc = documentEngine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = documentEngine.getDocument(testContract, documentName); assertEq(doc.uri, ""); assertEq(doc.documentHash, ""); assertEq(doc.lastModified, 0); bytes32[] memory docs = documentEngine.getAllDocuments(testContract); assertEq(docs.length, 0); - IERC1643.Document memory doc2 = documentEngine.getDocument( - testContract, - names[1] - ); + IERC1643.Document memory doc2 = documentEngine.getDocument(testContract, names[1]); assertEq(doc2.uri, ""); assertEq(doc2.documentHash, ""); assertEq(doc2.lastModified, 0); } - function testCannotRemoveBatchDocumentIfEmptyLengthForOnlyOneContract() - public - { + function testCannotRemoveBatchDocumentIfEmptyLengthForOnlyOneContract() public { bytes32[] memory names = new bytes32[](0); vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); diff --git a/test/DocumentEngineOwnable.t.sol b/test/DocumentEngineOwnable.t.sol index ae6ea06..9e2caa8 100644 --- a/test/DocumentEngineOwnable.t.sol +++ b/test/DocumentEngineOwnable.t.sol @@ -34,9 +34,7 @@ contract DocumentEngineOwnableTest is Test { } function testDeployRevertsWithZeroOwner() public { - vm.expectRevert( - abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, AddressZero) - ); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, AddressZero)); new DocumentEngineOwnable(AddressZero, AddressZero); } @@ -46,10 +44,7 @@ contract DocumentEngineOwnableTest is Test { vm.prank(owner); engine.setDocument(testContract, documentName, documentURI, documentHash); - IERC1643.Document memory doc = engine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = engine.getDocument(testContract, documentName); assertEq(doc.uri, documentURI); assertEq(doc.documentHash, documentHash); assertEq(doc.lastModified, block.timestamp); @@ -62,12 +57,7 @@ contract DocumentEngineOwnableTest is Test { function testNonOwnerCannotSetDocument() public { vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - Ownable.OwnableUnauthorizedAccount.selector, - attacker - ) - ); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, attacker)); engine.setDocument(testContract, documentName, documentURI, documentHash); } @@ -81,12 +71,7 @@ contract DocumentEngineOwnableTest is Test { function testNonOwnerCannotBindToken() public { vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - Ownable.OwnableUnauthorizedAccount.selector, - attacker - ) - ); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, attacker)); engine.bindToken(testContract); } @@ -97,10 +82,7 @@ contract DocumentEngineOwnableTest is Test { vm.prank(testContract); engine.setDocument(documentName, documentURI, documentHash); - IERC1643.Document memory doc = engine.getDocument( - testContract, - documentName - ); + IERC1643.Document memory doc = engine.getDocument(testContract, documentName); assertEq(doc.uri, documentURI); vm.prank(testContract); @@ -120,23 +102,13 @@ contract DocumentEngineOwnableTest is Test { // once unbound, the token can no longer self-manage vm.prank(testContract); - vm.expectRevert( - abi.encodeWithSelector( - TokenBindingModule.NotBoundToken.selector, - testContract - ) - ); + vm.expectRevert(abi.encodeWithSelector(TokenBindingModule.NotBoundToken.selector, testContract)); engine.setDocument(documentName, documentURI, documentHash); } function testUnboundTokenCannotSelfManage() public { vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - TokenBindingModule.NotBoundToken.selector, - attacker - ) - ); + vm.expectRevert(abi.encodeWithSelector(TokenBindingModule.NotBoundToken.selector, attacker)); engine.setDocument(documentName, documentURI, documentHash); } @@ -162,9 +134,7 @@ contract DocumentEngineOwnableTest is Test { assertTrue(engine.supportsInterface(type(IERC8303).interfaceId)); assertTrue(engine.supportsInterface(type(IERC165).interfaceId)); assertTrue(engine.supportsInterface(type(IERC1643).interfaceId)); - assertTrue( - engine.supportsInterface(type(IERC1643MultiDocument).interfaceId) - ); + assertTrue(engine.supportsInterface(type(IERC1643MultiDocument).interfaceId)); assertTrue(engine.supportsInterface(type(ITokenBinding).interfaceId)); // no role-based access control here assertFalse(engine.supportsInterface(type(IAccessControl).interfaceId)); From 1432af94fe0b9dffae20034a1e22e64c349f9f4e Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:41:49 +0200 Subject: [PATCH 17/19] perf: cache batch-loop length and use ++i in DocumentEngineBase --- src/DocumentEngineBase.sol | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/DocumentEngineBase.sol b/src/DocumentEngineBase.sol index f104daf..90286cf 100644 --- a/src/DocumentEngineBase.sol +++ b/src/DocumentEngineBase.sol @@ -120,7 +120,8 @@ abstract contract DocumentEngineBase is IERC1643, IERC1643MultiDocument, Documen ) { revert InvalidInputLength(); } - for (uint256 i = 0; i < subjects.length; i++) { + uint256 length = subjects.length; + for (uint256 i = 0; i < length; ++i) { _setDocument(subjects[i], names[i], uris[i], hashes[i]); } } @@ -137,7 +138,8 @@ abstract contract DocumentEngineBase is IERC1643, IERC1643MultiDocument, Documen if (names.length == 0 || names.length != uris.length || uris.length != hashes.length) { revert InvalidInputLength(); } - for (uint256 i = 0; i < names.length; ++i) { + uint256 length = names.length; + for (uint256 i = 0; i < length; ++i) { _setDocument(subject, names[i], uris[i], hashes[i]); } } @@ -150,7 +152,8 @@ abstract contract DocumentEngineBase is IERC1643, IERC1643MultiDocument, Documen revert InvalidInputLength(); } - for (uint256 i = 0; i < subjects.length; ++i) { + uint256 length = subjects.length; + for (uint256 i = 0; i < length; ++i) { _removeDocument(subjects[i], names[i]); } } @@ -163,7 +166,8 @@ abstract contract DocumentEngineBase is IERC1643, IERC1643MultiDocument, Documen revert InvalidInputLength(); } - for (uint256 i = 0; i < names.length; ++i) { + uint256 length = names.length; + for (uint256 i = 0; i < length; ++i) { _removeDocument(subject, names[i]); } } From 5b9634748f0fb93799d44eb4b62828a39c9f7fef Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:52:19 +0200 Subject: [PATCH 18/19] test: add event-emission, msg.sender-read, batch-guard, and fuzz coverage --- AGENTS.md | 10 ++- CLAUDE.md | 10 ++- test/DocumentEngine.t.sol | 177 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 189 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a86b3d..9688bd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,10 +92,12 @@ script/ └── DeployDocumentEngineOwnable.s.sol # Deploy Ownable variant (env: DOCUMENT_ENGINE_OWNER, _FORWARDER) test/ -├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, -│ # bound-token path, batch ops, ERC-8303, CMTAT integration -│ # (CMTATDocumentEngineMock built on DocumentEngineModule), -│ # flexible-authorization override (OpenDocumentEngine) +├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin + bound-token +│ # paths, batch ops (incl. name==0 / missing-doc guards), +│ # ERC-8303 + interface discovery, event emission (asserts the +│ # base events are NOT emitted), msg.sender-scoped reads, +│ # enumeration, fuzz round-trip/isolation, CMTAT integration +│ # (CMTATDocumentEngineMock), flexible-auth override (OpenDocumentEngine) ├── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, │ # token binding, two-step ownership, ERC-8303) └── Deploy.t.sol # Tests for the deployment scripts (deploy() state + run() env) diff --git a/CLAUDE.md b/CLAUDE.md index 6a86b3d..9688bd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,10 +92,12 @@ script/ └── DeployDocumentEngineOwnable.s.sol # Deploy Ownable variant (env: DOCUMENT_ENGINE_OWNER, _FORWARDER) test/ -├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin path, -│ # bound-token path, batch ops, ERC-8303, CMTAT integration -│ # (CMTATDocumentEngineMock built on DocumentEngineModule), -│ # flexible-authorization override (OpenDocumentEngine) +├── DocumentEngine.t.sol # Foundry tests: deploy, access control, admin + bound-token +│ # paths, batch ops (incl. name==0 / missing-doc guards), +│ # ERC-8303 + interface discovery, event emission (asserts the +│ # base events are NOT emitted), msg.sender-scoped reads, +│ # enumeration, fuzz round-trip/isolation, CMTAT integration +│ # (CMTATDocumentEngineMock), flexible-auth override (OpenDocumentEngine) ├── DocumentEngineOwnable.t.sol # Tests for the Ownable2Step deployment (owner path, │ # token binding, two-step ownership, ERC-8303) └── Deploy.t.sol # Tests for the deployment scripts (deploy() state + run() env) diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index b05af5b..9d17f3a 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -55,6 +55,13 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { bytes32 public constant DOCUMENT_MANAGER_ROLE = keccak256("DOCUMENT_MANAGER_ROLE"); address AddressZero = address(0); + // Local copies of the extension events, so `vm.expectEmit` can emit and match them. + event DocumentUpdatedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); + event DocumentRemovedForSubject(address indexed subject, bytes32 indexed name, string uri, bytes32 documentHash); + // Base ERC-1643 event signatures (this shared engine must NOT emit them). + bytes32 internal constant BASE_UPDATED_SIG = keccak256("DocumentUpdated(bytes32,string,bytes32)"); + bytes32 internal constant BASE_REMOVED_SIG = keccak256("DocumentRemoved(bytes32,string,bytes32)"); + function setUp() public { documentEngine = new DocumentEngine(admin, AddressZero); vm.prank(admin); @@ -618,4 +625,174 @@ contract DocumentEngineTest is Test, DocumentEngineInvariant, AccessControl { vm.prank(admin); documentEngine.batchRemoveDocuments(testContract, names); } + + /*////////////////////////////////////////////////////////////// + Events (emission responsibility) + //////////////////////////////////////////////////////////////*/ + + function testSetDocumentEmitsForSubjectEvent() public { + bytes32 name = keccak256("evt-doc"); + vm.expectEmit(true, true, false, true, address(documentEngine)); + emit DocumentUpdatedForSubject(testContract, name, documentURI, documentHash); + vm.prank(admin); + documentEngine.setDocument(testContract, name, documentURI, documentHash); + } + + function testRemoveDocumentEmitsForSubjectEvent() public { + // `documentName` for `testContract` was registered in setUp + vm.expectEmit(true, true, false, true, address(documentEngine)); + emit DocumentRemovedForSubject(testContract, documentName, documentURI, documentHash); + vm.prank(admin); + documentEngine.removeDocument(testContract, documentName); + } + + function testSetDocumentDoesNotEmitBaseEvent() public { + vm.recordLogs(); + vm.prank(admin); + documentEngine.setDocument(testContract, keccak256("evt-doc"), documentURI, documentHash); + _assertBaseEventNotEmitted(BASE_UPDATED_SIG); + } + + function testRemoveDocumentDoesNotEmitBaseEvent() public { + vm.recordLogs(); + vm.prank(admin); + documentEngine.removeDocument(testContract, documentName); + _assertBaseEventNotEmitted(BASE_REMOVED_SIG); + } + + /// @dev Asserts no recorded log emitted by the engine carries the base ERC-1643 signature. + function _assertBaseEventNotEmitted(bytes32 baseSig) internal { + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 i = 0; i < logs.length; ++i) { + if (logs[i].emitter == address(documentEngine)) { + assertTrue(logs[i].topics[0] != baseSig, "base ERC-1643 event must not be emitted"); + } + } + } + + /*////////////////////////////////////////////////////////////// + msg.sender-scoped reads (base ERC-1643) + //////////////////////////////////////////////////////////////*/ + + function testMsgSenderScopedReads() public { + // setUp registered `documentName` for `testContract`; read it as that caller + vm.prank(testContract); + IERC1643.Document memory doc = documentEngine.getDocument(documentName); + assertEq(doc.uri, documentURI); + assertEq(doc.documentHash, documentHash); + + vm.prank(testContract); + bytes32[] memory names = documentEngine.getAllDocuments(); + assertEq(names.length, 1); + assertEq(names[0], documentName); + } + + function testMsgSenderScopedReadReturnsEmptyForOther() public { + // `attacker` has no documents of its own + vm.prank(attacker); + IERC1643.Document memory doc = documentEngine.getDocument(documentName); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); + + vm.prank(attacker); + assertEq(documentEngine.getAllDocuments().length, 0); + } + + /*////////////////////////////////////////////////////////////// + Batch edge cases (name==0 / missing doc) + //////////////////////////////////////////////////////////////*/ + + function testBatchSetRevertsOnZeroName() public { + address[] memory subjects = new address[](1); + subjects[0] = testContract; + bytes32[] memory names = new bytes32[](1); + names[0] = bytes32(0); + string[] memory uris = new string[](1); + uris[0] = documentURI; + bytes32[] memory hashes = new bytes32[](1); + hashes[0] = documentHash; + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ERC1643InvalidName.selector)); + documentEngine.batchSetDocuments(subjects, names, uris, hashes); + } + + function testBatchRemoveRevertsOnMissingDocument() public { + address[] memory subjects = new address[](1); + subjects[0] = testContract; + bytes32[] memory names = new bytes32[](1); + names[0] = keccak256("never-set"); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(ERC1643MissingDocument.selector)); + documentEngine.batchRemoveDocuments(subjects, names); + } + + /*////////////////////////////////////////////////////////////// + Enumeration & fuzz + //////////////////////////////////////////////////////////////*/ + + function testEnumerationAfterMixedOps() public { + address subj = address(0xBEEF); + bytes32 n1 = keccak256("n1"); + bytes32 n2 = keccak256("n2"); + bytes32 n3 = keccak256("n3"); + + vm.startPrank(admin); + documentEngine.setDocument(subj, n1, "u1", bytes32(0)); + documentEngine.setDocument(subj, n2, "u2", bytes32(0)); + documentEngine.setDocument(subj, n3, "u3", bytes32(0)); + assertEq(documentEngine.getAllDocuments(subj).length, 3); + + // overwrite does not add a new entry + documentEngine.setDocument(subj, n2, "u2-updated", bytes32(0)); + assertEq(documentEngine.getAllDocuments(subj).length, 3); + + // removal shrinks the set (swap-and-pop) + documentEngine.removeDocument(subj, n2); + vm.stopPrank(); + + bytes32[] memory names = documentEngine.getAllDocuments(subj); + assertEq(names.length, 2); + assertTrue( + (names[0] == n1 && names[1] == n3) || (names[0] == n3 && names[1] == n1), + "remaining names must be n1 and n3" + ); + } + + function testFuzzSetGetRemoveRoundTrip(address subject, bytes32 name, string calldata uri, bytes32 hash) public { + vm.assume(name != bytes32(0)); // the null name reverts by design + + vm.prank(admin); + documentEngine.setDocument(subject, name, uri, hash); + + IERC1643.Document memory doc = documentEngine.getDocument(subject, name); + assertEq(doc.uri, uri); + assertEq(doc.documentHash, hash); + assertEq(doc.lastModified, block.timestamp); + + vm.prank(admin); + documentEngine.removeDocument(subject, name); + + doc = documentEngine.getDocument(subject, name); + assertEq(doc.uri, ""); + assertEq(doc.documentHash, ""); + assertEq(doc.lastModified, 0); + } + + function testFuzzDocumentsAreIsolatedPerSubject(address subjectA, address subjectB, bytes32 name) public { + vm.assume(name != bytes32(0)); + vm.assume(subjectA != subjectB); + // `testContract` is pre-populated in setUp; exclude it from the "untouched" subject + vm.assume(subjectB != testContract); + + vm.prank(admin); + documentEngine.setDocument(subjectA, name, documentURI, documentHash); + + // subjectB is unaffected + IERC1643.Document memory docB = documentEngine.getDocument(subjectB, name); + assertEq(docB.lastModified, 0); + assertEq(documentEngine.getAllDocuments(subjectB).length, 0); + } } From b26b74f836bec1fc24565251f05a7d6804a25814 Mon Sep 17 00:00:00 2001 From: Ryan Sauge <71391932+rya-sge@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:01:49 +0200 Subject: [PATCH 19/19] refactor: remove unused DocumentNotFound error --- src/DocumentEngineInvariant.sol | 1 - 1 file changed, 1 deletion(-) diff --git a/src/DocumentEngineInvariant.sol b/src/DocumentEngineInvariant.sol index 93bfb37..3428b6c 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -11,7 +11,6 @@ pragma solidity ^0.8.20; * contract is only ever used as a base, never deployed on its own. */ abstract contract DocumentEngineInvariant { - error DocumentNotFound(address subject, bytes32 name); error InvalidInputLength(); error AdminWithAddressZeroNotAllowed();