Targets CMTAT v3.3.0-rc3 — see the compatibility matrix for which CMTAT release each version of this engine is built against.
Versioning note.
getDocumentchanges shape relative tov0.3.0, which the convention above classifies as a MAJOR bump.MINORis used because the project is still in its0.xline, where a1.0.0would wrongly signal a stable, audited release. Treat this release as breaking for any consumer decodinggetDocument.
Changed
-
Dependencies
- Upgrade CMTAT
v2.5.0-rc0→v3.3.0-rc3(lib/CMTAT→658672f190d56d3f61663a7d6d51962b8980df70). Development passed throughv3.3.0-rc1andv3.3.0-rc2. rc1 is not compatible with the code as shipped here, because it declares neither the ERC-1643 errors nor the flatgetDocumentreturn (see below); rc2 and rc3 are interchangeable for this engine — between them, the whole document surface (draft-IERC1643.sol,IDocumentEngine.sol,DocumentEngineModule.sol,DocumentERC1643Module.sol) changed only its pragma,^0.8.20→^0.8.24. Both are therefore listed as the supported range, verified by building and running the full suite against each (74/74 on both). Nothing below rc2 works:v3.0.0/v3.1.0/v3.2.0return aDocumentstruct and declare no interface errors, and they predate CMTAT's token-sideDocumentEngineModuleentirely. - Upgrade OpenZeppelin Contracts (and Contracts Upgradeable)
v5.0.2→v5.7.0.v5.7.0deprecatesEnumerableSet.at()in favour ofpos()(the old name clashes with a keyword scheduled for Solidity);at()remains as a forwarding alias, and this engine has no call sites either way. The only exposure is inherited —AccessControlEnumerable.getRoleMemberswitched topos()internally, with no change to its signature, selector or behaviour. Verified:DocumentEngine's runtime code is byte-identical acrossv5.6.1andv5.7.0(8436 bytes; only the CBOR metadata trailer moves, because the source text ofAccessControlEnumerable.solchanged), andDocumentEngineOwnable's bytecode is unchanged including metadata. - Add CMTA/RuleEngine
v3.0.0-rc5as a submodule (binding-pattern reference; see Why not reuse RuleEngine's compliance module? — itsERC3643ComplianceExtendedModuleis not reused) foundry.locknow records every submodule by tag; all five entries had gone stale sincev0.3.0.
- Upgrade CMTAT
-
Toolchain: bump Solidity
0.8.26→0.8.34andevm_versioncancun→pragueto match CMTAT v3 (CMTAT usesrequire(cond, CustomError()), which needs solc ≥ 0.8.27) -
Code-quality review (
doc/audits/tools/v0.4.0/claude/CLAUDE_ANALYSIS.md) — 14 findings, none a vulnerability. Six implemented:- Gas,
_removeDocumentName: the_documentNames[subject]mapping slot was re-hashed on every loop iteration; cached as a storage pointer. Measured −2200 gas on a 20-entry full scan. - Gas,
_removeDocument: the wholeDocument(URI included) was copied to memory to be read three times; now read through a storage pointer. A further −645 gas. Combined, removal is −2845 gas (−3.3 %) worst case. The emit must stay ahead of thedelete— verified by mutating the order and confirmingtestRemoveDocumentEmitsForSubjectEventfails. Side effect: Slither'sincorrect-equality(Medium) andtimestamp(Low) stopped firing on the unchangeddoc.lastModified == 0, taking it from 4 results to 2. Not a fix — both were already false positives and the detector merely loses the taint through a storage pointer. hasRoleNatSpec: documented that a role is unrevokable from the default admin —revokeRolesucceeds, emitsRoleRevokedand dropsgetRoleMemberCount, yet the admin keeps the access. Not a privilege issue (an admin can re-grant itself anything) but the call misreports. Pinned by the newtestRevokingRoleFromDefaultAdminDoesNotRemoveAccess.DocumentEngineInvariant: the error-location comment misattributedNotBoundToken(address)toITokenBinding; it is declared byTokenBindingModule.- Documentation pointers removed from contract comments. Three comments referenced
doc/ERCSpecification…; documentation moves but deployed source does not, and this repo had already renamed that file once (ERC-1643-proposition.md→erc-draft_multi_document_management.md), leaving a dangling README link behind. Someone reading verified source on an explorer has the comment and not the file. All three pointers are gone and each comment is now shorter, not longer — theIERC1643MultiDocumentheader dropped from 10 lines to 9 by replacing an enumeration that gestured at the draft's rationale with the one operative fact:subjectneed not be a token. - All 12
internalfunctions are nowvirtual(_setDocument,_removeDocument,_removeDocumentName,_getDocument,_setTokenBinding,_checkTokenBound, and the ERC-2771 context trio in both deployments), resolving an inconsistency whereTokenBindingModuleexposed its public surface for override whileDocumentEngineBaseexposed nothing but its two abstract hooks. A deployment can now override the document write/read paths and the binding check, matching what CMTAT's equivalent module allows. Runtime cost is zero: the executable bytecode of both deployments is byte-identical before and after (7457 / 6111 bytes, metadata trailer excluded). Guarded byOverridingDocumentEngine+testInternalHooksAreVirtualAndOverridesAreReached— removingvirtualfrom any of the three overridden hooks fails the build (Error (4334): Trying to override non-virtual function).
Notable non-changes, recorded so they are not re-raised:
unchecked { ++i }buys 0 gas on solc 0.8.34 (measured);string calldataon the adminsetDocumentis 49 gas worse thanmemory(measured); and the duplicated ERC-2771 context overrides cannot be extracted into a shared module — C3 linearization forces each deployment to re-state them, proven by compiler error. - Gas,
-
Style pass across
src/andscript/— behaviour-preserving. Brought the sources in line with the Solidity style guide: functions reordered by visibility group (external → public → internal,view/purelast within each), so the_authorize*hooks and the ERC-2771 context overrides now follow the public API instead of preceding it; every brace-less global import replaced by a named one (which required adding the previously implicitContextandAccessControlimports, since a named import no longer re-exports a dependency's own imports); and NatSpec completed with a@paramper argument and a@returnper return value. No signature, visibility, body or storage layout changed — verified by an unchanged per-contract function set, a cleanforge build, and 72/72 tests passing. -
Source pragma raised
^0.8.20→^0.8.24acrosssrc/,script/andtest/. This is a correction, not a new restriction:^0.8.20had become an over-promise, advertising a range the sources could not actually compile in. OpenZeppelin'sAccessControlEnumerable.solandEnumerableSet.solare^0.8.24, and CMTATv3.3.0-rc3moveddraft-IERC1643.solto^0.8.24as well, so every contract insrc/now transitively requires it —forge build --use 0.8.23fails to resolve a compiler.0.8.24is the realsrc/floor; the full project including the CMTAT-importing tests needs0.8.27, becauserequire(cond, CustomError())is restricted to the via-ir pipeline before then. Deployed bytecode is unaffected — the pinned compiler is still0.8.34. -
IERC1643(CMTAT v3) breaking changes-
getDocumentkeeps returning(string uri, bytes32 documentHash, uint256 lastModified)— the flat ERC-1643 ABI — on both overloads,getDocument(bytes32)andgetDocument(address subject, bytes32). CMTATv3.3.0-rc1briefly replaced this with aDocumentstruct andv3.3.0-rc2reverted it; this engine follows rc2/rc3, so relative tov0.3.0the external shape is unchanged.The distinction is worth recording because it is invisible to interface detection: return types are not part of a function signature, so both shapes share the same selectors and the same
type(IERC1643).interfaceId(0xecfecec8). A consumer built from the specification ABI decodes a struct return as garbage without reverting —uribecomes binary junk,documentHashbecomes0x…60, andlastModifiedbecomes the real hash as auint256.getDocumentis now covered bytestGetDocumentReturnsFlatErc1643Abi, which inspects the returndata directly since ERC-165 structurally cannot. -
The
Documentstruct and theDocumentUpdated/DocumentRemovedevents are now provided byIERC1643; the duplicate local declarations were removed fromDocumentEngineInvariant. The struct is retained internally for storage only. -
ERC1643InvalidName()/ERC1643MissingDocument()are likewise declared byIERC1643as of CMTATv3.3.0-rc2and are not re-declared here. The multi-subject draft requires a contract implementing both interfaces to obtain each error exactly once ("MUST NOT declare them twice"), and re-declaring is a compile error. Selectors, and hence revert data, are unchanged. The same principle was applied to every other error:MultiDocumentInvalidSubject()moved toIERC1643MultiDocumentandTokenBindingInvalidToken()is declared onITokenBinding, so an ABI generated from an interface carries its errors.DocumentEngineInvariantnow holds onlyInvalidInputLengthandAdminWithAddressZeroNotAllowed, which no interface defines. -
Import path moved:
CMTAT/interfaces/engine/draft-IERC1643.sol→CMTAT/interfaces/tokenization/draft-IERC1643.sol.
-
-
ERC1643InvalidSubject()renamed toMultiDocumentInvalidSubject()and moved fromDocumentEngineInvarianttoIERC1643MultiDocument, matching the multi-subject draft. This changes the error selector, so integrators decoding this revert must update.The draft's rule is that an error is prefixed by the proposal that defines its condition, not by the one it sits next to. The null-
subjectcondition cannot arise in ERC-1643 at all — itssetDocumenthas nosubjectargument, so the subject is implicitly the contract itself, which is never the null address — so borrowing theERC1643prefix named the error after a standard in which it is unreachable. The two genuinely-shared errors keep their prefix for the opposite reason. -
Token binding rejects
address(0)and is idempotent.bindToken/unbindTokennow revertTokenBindingInvalidToken()on the null address — which can never call the engine, so binding it granted nothing while still emitting an event indexers key on — and write plus emitTokenBindingSetonly when the binding actually changes. A repeated call still succeeds, since the caller's intent already holds, but emits nothing, so every event in the log is a real transition and an indexer never has to de-duplicate.
Added
- Bound-token document management: implement the now-mandatory
IERC1643.setDocument(name, uri, hash)andremoveDocument(name), gated by theonlyBoundTokenmodifier and scoped to the caller (_msgSender()) own namespace. A token bound withbindToken(token)(see the shared binding module below) manages its own documents and can never affect another contract's documents. The admin overloads (explicitaddress,DOCUMENT_MANAGER_ROLE) are unchanged, so both systems work side by side. (RuleEngine'sERC3643ComplianceExtendedModulewas evaluated for the binding but intentionally not reused — see the README.) - Optional multi-token events: alongside the standard
IERC1643events, the engine now also emitsDocumentUpdatedForContract/DocumentRemovedForContract, which carry thesmartContract(token) address so off-chain indexers can tell which contract a document belongs to during multi-contract operations. Seeerc-draft_multi_document_management.mdfor the proposed optional standard extension. - Flexible access control (CMTAT / RuleEngine pattern): the restricted functions use the
onlyDocumentManager/onlyBoundTokenmodifiers, which delegate to overridableinternal virtualauthorization hooks_authorizeDocumentManagement()/_authorizeBoundTokenDocumentManagement(). Each deployment implements the admin hook (DOCUMENT_MANAGER_ROLEorowner); the bound-token hook is implemented once byTokenBindingModule(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), whileDocumentEngineis the deployment contract that defines the access control (AccessControl, the concrete hooks andhasRole) and the ERC-2771 wiring. The deployableDocumentEngineAPI and behavior are unchanged. - Version module implementing ERC-8303: the version is now exposed through a dedicated
VersionModule(src/modules/VersionModule.sol) implementing theIERC8303interface (src/interfaces/IERC8303.sol). It adds a standardversion()view function (in addition to the existing publicVERSIONconstant) and advertises ERC-8303 via ERC-165 (supportsInterface(0x54fd4d50) == true).DocumentEnginecombines the module'ssupportsInterfacewith the access-control base. - Second deployment
DocumentEngineOwnable(src/DocumentEngineOwnable.sol): an alternative deployment that uses OpenZeppelinOwnable2Step(single owner, two-step transfer) instead of role-based access control, reusing the sameDocumentEngineBaselogic and the sharedTokenBindingModule. Both document management and token binding are restricted to theowner.
Changed (access control)
DocumentEnginenow inheritsAccessControlEnumerableinstead ofAccessControl, adding on-chain enumeration of role members (getRoleMember,getRoleMemberCount) and advertisingIAccessControlEnumerablevia ERC-165. Default authorization behavior is unchanged.- Moved the
DOCUMENT_MANAGER_ROLEconstant out of the sharedDocumentEngineInvariantand into the role-basedDocumentEngine, soDocumentEngineInvariant(and theDocumentEngineOwnabledeployment) 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 (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/DocumentRemovedevents (the spec'sMUST NOTfor a shared manager — those events carry nosubjectand belong on the token contract). -
Extension events/interface. Renamed the multi-token events to the standard
DocumentUpdatedForSubject/DocumentRemovedForSubject(parametersubject), and introduced theIERC1643MultiDocumentinterface (src/interfaces/IERC1643MultiDocument.sol) that the base now implements — the address-scopedgetDocument/getAllDocuments/setDocument/removeDocument. -
Input validation.
setDocumentnow revertsERC1643InvalidName()whenname == bytes32(0)andMultiDocumentInvalidSubject()whensubject == address(0)(the multi-subject draft's null-namespace guard);removeDocumentnow revertsERC1643MissingDocument()for a non-existent document (previously it silently emitted a spurious removal event). Seeerc-draft_multi_document_management.mdfor the corresponding multi-subject draft. -
ERC-165 discovery.
supportsInterfacenow returnstruefortype(IERC1643).interfaceId,type(IERC1643MultiDocument).interfaceIdandtype(ITokenBinding).interfaceId(both deployments).The base id is advertised because the engine implements the base single-argument functions, and because a token uses it: before wiring itself to the engine with
setDocumentEngine(engine), or before forwardingsetDocument(name, uri, hash), it can confirm through ERC-165 that those endpoints exist. It does not mean documents should be read from the engine's address — the base functions are_msgSender()-scoped, so a third-party read returns the caller's own empty namespace. Documented in the README and asserted bytestBaseERC1643IsAdvertisedButReadsAreCallerScoped.
Added (token binding)
- Shared
ITokenBindinginterface +TokenBindingModule.bindToken(token)/unbindToken(token)/isTokenBound(token)+TokenBindingSetevent (src/interfaces/ITokenBinding.sol), implemented once for both deployments bysrc/modules/TokenBindingModule.sol— a single allowlist, not a role. Both deployments now share the exact same binding mechanism (same functions, event, andNotBoundTokenrevert on an unbound write) and advertisetype(ITokenBinding).interfaceIdvia ERC-165. The role deployment no longer usesTOKEN_CONTRACT_ROLE(removed) — binding is authorized by the document-management hook (DOCUMENT_MANAGER_ROLE, or theownerinDocumentEngineOwnable).
Notes / bottlenecks
- Subject-side emission is CMTAT
v3.3.0-rc2or later. rc2 madeDocumentEngineModulere-emit the standardDocumentUpdated/DocumentRemovedon the token's own address after forwarding to the engine, and revert withCMTAT_DocumentEngineModule_NoDocumentEnginewhen no engine is set. Combined with this engine emitting only the address-carrying*ForSubjectevents, the subject-initiated call topology is fully conformant with the multi-subject draft's Emission Responsibility rules. The admin path remains non-conformant by construction — a write sent straight to the engine has no execution point in the subject, so the subject emits nothing. SeeOPEN-2inAUDIT_OVERVIEW.md. - Open items are tracked under Known open items in
AUDIT_OVERVIEW.md: the most severe is admin-path call topology (OPEN-2); also authorization granularity (OPEN-1) and enumeration cost (OPEN-4). - 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'sDocumentEngineModule(setDocumentEngine). The test suite was updated to exercise this real integration path via a minimal token built onDocumentEngineModule.