Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1233b42
Update to CMTAT v3.3.0-rc1 and other related librares, implement ERC-…
rya-sge Jul 22, 2026
afb03c2
refactor: delegate access control to overridable _authorize hooks (CM…
rya-sge Jul 22, 2026
19cdb8f
refactor: split DocumentEngine into DocumentEngineBase (logic) + Docu…
rya-sge Jul 22, 2026
f544ce5
feat: add VersionModule implementing ERC-8303 (version() + ERC-165)
rya-sge Jul 22, 2026
183b648
feat: use AccessControlEnumerable and add DocumentEngineOwnable (Owna…
rya-sge Jul 22, 2026
1b9e49e
refactor: move role constants from DocumentEngineInvariant to Documen…
rya-sge Jul 22, 2026
a6a8a7c
revert: keep purpose-built token binding; document why RuleEngine's E…
rya-sge Jul 22, 2026
8fcd114
Add multi token support
rya-sge Jul 22, 2026
113a348
fix: conform DocumentEngine to updated ERC-1643 (emission responsibil…
rya-sge Jul 22, 2026
0bf09e9
feat: add Foundry deployment scripts for both DocumentEngine variants…
rya-sge Jul 22, 2026
2e3e114
Environment Variables
rya-sge Jul 22, 2026
34ec34d
refactor: unify document-manager parameter naming on subject/subjects
rya-sge Jul 22, 2026
a839562
feat: add shared ITokenBinding surface (bindToken/unbindToken/isToken…
rya-sge Jul 22, 2026
a77a922
docs: clarify base/invariant/hasRole NatSpec and mark DocumentEngine…
rya-sge Jul 22, 2026
f1ae06d
refactor: unify token binding into shared TokenBindingModule (allowli…
rya-sge Jul 22, 2026
ddc0881
style: adopt forge fmt as the canonical formatter (drop prettier), r…
rya-sge Jul 22, 2026
1432af9
perf: cache batch-loop length and use ++i in DocumentEngineBase
rya-sge Jul 22, 2026
5b96347
test: add event-emission, msg.sender-read, batch-guard, and fuzz cov…
rya-sge Jul 22, 2026
b26b74f
refactor: remove unused DocumentNotFound error
rya-sge Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ docs/

# Dotenv file
.env

history
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -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
145 changes: 145 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 85 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading
Loading