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/.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..9688bd1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,145 @@ +# 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** — 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)`, + 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`) + irrelevant to a document engine. See the README rationale section. +- **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). +- **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions + use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to + 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 + keyed by the token address). Standard CMTAT standalone tokens store documents + on-chain instead and do **not** use this engine. + +## File tree + +``` +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 #1: role-based access control +│ # (AccessControlEnumerable, DOCUMENT_MANAGER_ROLE, +│ # _authorizeDocumentManagement, hasRole), ERC-2771, +│ # supportsInterface, constructor +├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of +│ # roles; document mgmt + binding are owner-only +├── 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) +│ └── 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") + └── 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, +│ # 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 + 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) +``` + +**Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds +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: + +- `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 `v3.0.0-rc4` (binding-pattern reference only; compliance module not reused) +- 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 `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 + that isolation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 199d877..a6266bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,90 @@ # 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 the formatter + +> forge fmt + +- 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) `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. + - 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**: 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 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 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 `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) + +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). + +### Added (token binding) + +- **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 + +- 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..9688bd1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,145 @@ +# 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** — 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)`, + 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`) + irrelevant to a document engine. See the README rationale section. +- **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). +- **Flexible access control (CMTAT / RuleEngine pattern):** restricted functions + use the `onlyDocumentManager` / `onlyBoundToken` modifiers, which delegate to + 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 + keyed by the token address). Standard CMTAT standalone tokens store documents + on-chain instead and do **not** use this engine. + +## File tree + +``` +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 #1: role-based access control +│ # (AccessControlEnumerable, DOCUMENT_MANAGER_ROLE, +│ # _authorizeDocumentManagement, hasRole), ERC-2771, +│ # supportsInterface, constructor +├── DocumentEngineOwnable.sol # Deployment #2: Ownable2Step (single owner) instead of +│ # roles; document mgmt + binding are owner-only +├── 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) +│ └── 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") + └── 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, +│ # 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 + 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) +``` + +**Contract split (CMTAT module/deployment pattern):** `DocumentEngineBase` holds +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: + +- `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 `v3.0.0-rc4` (binding-pattern reference only; compliance module not reused) +- 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 `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 + that isolation. diff --git a/README.md b/README.md index 380053e..14801ce 100644 --- a/README.md +++ b/README.md @@ -13,37 +13,210 @@ 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.** 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 -function setDocument(address smartContract,bytes32 name_,string memory uri_, bytes32 documentHash_) +documentEngine.bindToken(address(token)); // also: unbindToken(token), isTokenBound(token) ``` +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: + +```solidity +// DocumentEngine (standard ERC-1643, scoped to msg.sender) +function setDocument(bytes32 name_, string calldata uri_, bytes32 documentHash_) external; +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, +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); // or _checkOwner() +} + +// 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 changes *who* is authorized by overriding the hook, never by +touching the management functions. + +### 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 + +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 + +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. + + + +## 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** (`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. 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) +(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 @@ -69,14 +242,16 @@ function setDocument(address smartContract,bytes32 name_,string memory uri_, byt | :----------------: | :------------------: | :----------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DocumentEngine** | Implementation | IERC1643, DocumentEngineInvariant, AccessControl | | | +| **DocumentEngine** | Implementation | DocumentEngineBase, VersionModule, AccessControlEnumerable, ERC2771Context | | | | └ | | Public ❗️ | 🛑 | NO❗️ | -| └ | setDocument | Public ❗️ | 🛑 | onlyRole | -| └ | removeDocument | External ❗️ | 🛑 | onlyRole | -| └ | 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❗️ | @@ -112,18 +287,23 @@ 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 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 -### 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 @@ -197,10 +377,40 @@ $ 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) | + +> **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 -$ 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/doc/ERCSpecification/erc-1643.md b/doc/ERCSpecification/erc-1643.md new file mode 100644 index 0000000..28f63a7 --- /dev/null +++ b/doc/ERCSpecification/erc-1643.md @@ -0,0 +1,220 @@ +--- +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", "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. + +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. + +`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`: + - 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. + +### 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. + +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. + +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: + +- 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. +- 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 + +Copyright and related rights waived via [CC0](../LICENSE.md). 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/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..67b08dd 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,10 +1,19 @@ [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' + +# `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/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..66fcf2a --- /dev/null +++ b/lib/RuleEngine @@ -0,0 +1 @@ +Subproject commit 66fcf2aafebd1f9d9de8a81dec92b88da071c9b3 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/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/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/script/DeployDocumentEngine.s.sol b/script/DeployDocumentEngine.s.sol new file mode 100644 index 0000000..d76e75f --- /dev/null +++ b/script/DeployDocumentEngine.s.sol @@ -0,0 +1,42 @@ +//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 + * + * 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) { + 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..e7b7e92 --- /dev/null +++ b/script/DeployDocumentEngineOwnable.s.sol @@ -0,0 +1,42 @@ +//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 + * + * 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) { + 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/src/DocumentEngine.sol b/src/DocumentEngine.sol index 3f4fff9..7b1a17e 100644 --- a/src/DocumentEngine.sol +++ b/src/DocumentEngine.sol @@ -1,35 +1,34 @@ //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 {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 "CMTAT/interfaces/engine/draft-IERC1643.sol"; -import "./DocumentEngineInvariant.sol"; +import "./modules/TokenBindingModule.sol"; +import "./modules/VersionModule.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 `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 - IERC1643, - DocumentEngineInvariant, - AccessControl, - ERC2771Context -{ - /** - * @notice - * Get the current version of the smart contract - */ - string public constant VERSION = "0.3.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; +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"); // 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(); } @@ -37,221 +36,57 @@ contract DocumentEngine is } /*////////////////////////////////////////////////////////////// - PUBLIC/EXTERNAL FUNCTIONS + ACCESS CONTROL (implementation) //////////////////////////////////////////////////////////////*/ /** - * @notice Restricted function to set or update a document - */ - function setDocument( - address smartContract, - bytes32 name_, - string memory uri_, - bytes32 documentHash_ - ) public onlyRole(DOCUMENT_MANAGER_ROLE) { - _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 onlyRole(DOCUMENT_MANAGER_ROLE) { - _removeDocument(smartContract, name_); - } - - /** - * @notice Batch version of setDocument to handle multiple documents at once + * @dev Authorization for the admin document-management path. + * The caller must hold `DOCUMENT_MANAGER_ROLE`. Override to customize. */ - function batchSetDocuments( - address[] calldata smartContracts, - bytes32[] calldata names, - string[] calldata uris, - bytes32[] calldata hashes - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { - 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 onlyRole(DOCUMENT_MANAGER_ROLE) { - 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]); - } + function _authorizeDocumentManagement() internal view virtual override { + _checkRole(DOCUMENT_MANAGER_ROLE); } /** - * @notice Batch version of removeDocument to handle multiple documents at once + * @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 batchRemoveDocuments( - address[] calldata smartContracts, - bytes32[] calldata names - ) external onlyRole(DOCUMENT_MANAGER_ROLE) { - 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 onlyRole(DOCUMENT_MANAGER_ROLE) { - if (names.length == 0) { - revert InvalidInputLength(); - } - - for (uint256 i = 0; i < names.length; ++i) { - _removeDocument(smartContract, names[i]); - } - } - - /** - * @notice Public function to get a document from msg.sender - */ - function getDocument( - bytes32 name_ - ) external view override returns (string memory, bytes32, uint256) { - return _getDocument(msg.sender, name_); - } - - /** - * @notice Public function to get a document for a specific contract address - */ - function getDocument( - address smartContract, - bytes32 name_ - ) external view returns (string memory, bytes32, uint256) { - return _getDocument(smartContract, name_); - } - - /** - * @notice Get all document names for msg.sender - */ - function getAllDocuments() - external + function hasRole(bytes32 role, address account) + public view - override - returns (bytes32[] memory) + virtual + override(AccessControl, IAccessControl) + returns (bool) { - return _documentNames[msg.sender]; - } - - /** - * @notice Get all document names for a specific smart contract - */ - function getAllDocuments( - address smartContract - ) external view returns (bytes32[] memory) { - return _documentNames[smartContract]; - } - - /* ============ ACCESS CONTROL ============ */ - /* - * @dev Returns `true` if `account` has been granted `role`. - */ - function hasRole( - bytes32 role, - address account - ) public view virtual override 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); - } - - /*////////////////////////////////////////////////////////////// - INTERNAL FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /** - * @dev Internal function to fetch a document - */ - 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); + return super.hasRole(role, account); } /** - * @dev Internal helper to remove the document name from the list of document names + * @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 _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_]; - emit DocumentRemoved(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; - emit DocumentUpdated(smartContract, name_, uri_, documentHash_); + 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); } /*////////////////////////////////////////////////////////////// @@ -261,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 new file mode 100644 index 0000000..90286cf --- /dev/null +++ b/src/DocumentEngineBase.sol @@ -0,0 +1,264 @@ +//SPDX-License-Identifier: MPL-2.0 +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"; + +/** + * @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, 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; + + /*////////////////////////////////////////////////////////////// + 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 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 { + _removeDocument(subject, 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. + * 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_, 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 subjects, + bytes32[] calldata names, + string[] calldata uris, + bytes32[] calldata hashes + ) external onlyDocumentManager { + if ( + subjects.length == 0 || subjects.length != names.length || names.length != uris.length + || uris.length != hashes.length + ) { + revert InvalidInputLength(); + } + uint256 length = subjects.length; + for (uint256 i = 0; i < length; ++i) { + _setDocument(subjects[i], names[i], uris[i], hashes[i]); + } + } + + /** + * @notice Batch version of setDocument to handle multiple documents at once + */ + function batchSetDocuments( + address subject, + 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(); + } + uint256 length = names.length; + for (uint256 i = 0; i < length; ++i) { + _setDocument(subject, names[i], uris[i], hashes[i]); + } + } + + /** + * @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)) { + revert InvalidInputLength(); + } + + uint256 length = subjects.length; + for (uint256 i = 0; i < length; ++i) { + _removeDocument(subjects[i], names[i]); + } + } + + /** + * @notice Batch version of removeDocument to handle multiple documents at once + */ + function batchRemoveDocuments(address subject, bytes32[] calldata names) external onlyDocumentManager { + if (names.length == 0) { + revert InvalidInputLength(); + } + + uint256 length = names.length; + for (uint256 i = 0; i < length; ++i) { + _removeDocument(subject, 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 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) { + return _documentNames[_msgSender()]; + } + + /** + * @notice Get all document names for a specific smart contract + */ + function getAllDocuments(address subject) external view override returns (bytes32[] memory) { + return _documentNames[subject]; + } + + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Internal function to fetch a document + */ + 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 { + 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].pop(); + break; + } + } + } + + 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[subject][name_]; + _removeDocumentName(subject, name_); + } + + 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(); + } + + Document storage doc = _documents[subject][name_]; + if (doc.lastModified == 0) { + // new document + _documentNames[subject].push(name_); + } + doc.uri = uri_; + doc.documentHash = documentHash_; + doc.lastModified = block.timestamp; + + // 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 b3836d8..3428b6c 100644 --- a/src/DocumentEngineInvariant.sol +++ b/src/DocumentEngineInvariant.sol @@ -1,31 +1,24 @@ //SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; -contract DocumentEngineInvariant { - error DocumentNotFound(address smartContract, bytes32 name); +/** + * @title DocumentEngineInvariant + * @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}). This + * contract is only ever used as a base, never deployed on its own. + */ +abstract contract DocumentEngineInvariant { error InvalidInputLength(); error AdminWithAddressZeroNotAllowed(); - event DocumentUpdated( - address smartContract, - bytes32 name, - string uri, - bytes32 documentHash - ); - event DocumentRemoved( - address smartContract, - bytes32 name, - string uri, - bytes32 documentHash - ); + /// @notice Reverts when `setDocument` is called with `name == bytes32(0)`. + /// @dev ERC-1643-recommended error name. + error ERC1643InvalidName(); - // Document structure - struct Document { - string uri; - bytes32 documentHash; - uint256 lastModified; - } - - bytes32 public constant DOCUMENT_MANAGER_ROLE = - keccak256("DOCUMENT_MANAGER_ROLE"); + /// @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 new file mode 100644 index 0000000..e8227cc --- /dev/null +++ b/src/DocumentEngineOwnable.sol @@ -0,0 +1,76 @@ +//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 {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 "./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 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 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) {} + + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL (implementation) + //////////////////////////////////////////////////////////////*/ + + /** + * @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 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); + } + + /*////////////////////////////////////////////////////////////// + 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/src/interfaces/IERC1643MultiDocument.sol b/src/interfaces/IERC1643MultiDocument.sol new file mode 100644 index 0000000..f0dbd42 --- /dev/null +++ b/src/interfaces/IERC1643MultiDocument.sol @@ -0,0 +1,37 @@ +// 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/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/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/src/modules/TokenBindingModule.sol b/src/modules/TokenBindingModule.sol new file mode 100644 index 0000000..245fddc --- /dev/null +++ b/src/modules/TokenBindingModule.sol @@ -0,0 +1,68 @@ +// 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/src/modules/VersionModule.sol b/src/modules/VersionModule.sol new file mode 100644 index 0000000..e0957f6 --- /dev/null +++ b/src/modules/VersionModule.sol @@ -0,0 +1,37 @@ +// 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/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)); + } +} diff --git a/test/DocumentEngine.t.sol b/test/DocumentEngine.t.sol index fda9890..9d17f3a 100644 --- a/test/DocumentEngine.t.sol +++ b/test/DocumentEngine.t.sol @@ -5,7 +5,40 @@ import "forge-std/Test.sol"; import "../src/DocumentEngine.sol"; import "../src/DocumentEngineInvariant.sol"; import "OZ/access/AccessControl.sol"; -import "CMTAT/CMTAT_STANDALONE.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 {TokenBindingModule} from "../src/modules/TokenBindingModule.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 {} +} + +/** + * @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); @@ -17,45 +50,22 @@ 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"); address AddressZero = address(0); - CMTAT_STANDALONE cmtat; + + // 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); - documentEngine.setDocument( - testContract, - documentName, - 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 - ); + documentEngine.setDocument(testContract, documentName, documentURI, documentHash); } /*////////////////////////////////////////////////////////////// @@ -69,9 +79,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); } @@ -82,28 +90,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); } @@ -127,21 +122,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); } @@ -157,58 +144,190 @@ 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); } /*////////////////////////////////////////////////////////////// - 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( - address(cmtat), - documentName, - documentURI, - documentHash - ); - vm.prank(admin); - cmtat.setDocumentEngine(documentEngine); + documentEngine.setDocument(address(cmtat), documentName, documentURI, documentHash); - // 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 (shared ITokenBinding allowlist / TokenBindingModule) + //////////////////////////////////////////////////////////////*/ + + function testBoundTokenCanManageOwnDocument() public { + // Bind the token to the engine (shared ITokenBinding surface) + vm.prank(admin); + documentEngine.bindToken(testContract); + assertTrue(documentEngine.isTokenBound(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 testNonAdminCannotBindToken() public { + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(AccessControlUnauthorizedAccount.selector, attacker, DOCUMENT_MANAGER_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); + 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)); + 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); + } + + /*////////////////////////////////////////////////////////////// + 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 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)); + // ...and the shared token-binding surface + assertTrue(documentEngine.supportsInterface(type(ITokenBinding).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.bindToken(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)); + assertTrue(documentEngine.supportsInterface(type(IAccessControl).interfaceId)); + } + + function testDoesNotSupportInvalidInterface() public { + assertFalse(documentEngine.supportsInterface(bytes4(0xffffffff))); } /*////////////////////////////////////////////////////////////// @@ -217,29 +336,18 @@ 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); - (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 { // 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); @@ -248,19 +356,13 @@ 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 - (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 +389,16 @@ 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 +422,16 @@ 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 +498,10 @@ 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 +524,17 @@ 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 +580,16 @@ 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,30 +606,193 @@ 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); - } - - function testCannotRemoveBatchDocumentIfEmptyLengthForOnlyOneContract() - public - { + IERC1643.Document memory doc2 = documentEngine.getDocument(testContract, names[1]); + assertEq(doc2.uri, ""); + assertEq(doc2.documentHash, ""); + assertEq(doc2.lastModified, 0); + } + + function testCannotRemoveBatchDocumentIfEmptyLengthForOnlyOneContract() public { bytes32[] memory names = new bytes32[](0); vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); 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); + } } diff --git a/test/DocumentEngineOwnable.t.sol b/test/DocumentEngineOwnable.t.sol new file mode 100644 index 0000000..9e2caa8 --- /dev/null +++ b/test/DocumentEngineOwnable.t.sol @@ -0,0 +1,143 @@ +//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"; +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; + 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.bindToken(testContract); + assertTrue(engine.isTokenBound(testContract)); + } + + function testNonOwnerCannotBindToken() public { + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, attacker)); + engine.bindToken(testContract); + } + + function testBoundTokenCanManageOwnDocument() public { + vm.prank(owner); + engine.bindToken(testContract); + + 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 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(TokenBindingModule.NotBoundToken.selector, testContract)); + engine.setDocument(documentName, documentURI, documentHash); + } + + function testUnboundTokenCannotSelfManage() public { + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(TokenBindingModule.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)); + 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))); + } +}