diff --git a/.gitignore b/.gitignore index 74e2350..8360cf2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ typechain-types cache artifacts +# +FEEDBACK.md +ISSUE.md +./.codex +.codex diff --git a/AGENTS b/AGENTS new file mode 100644 index 0000000..085169f --- /dev/null +++ b/AGENTS @@ -0,0 +1,9 @@ +SnapshotEngine is a Solidity/Hardhat codebase for on-chain ERC-20 snapshots. + +Main point: +- Schedule, reschedule, and execute snapshot times. +- Record historical account balances and total supply at snapshot boundaries. +- Expose snapshot state for downstream on-chain features like dividends, rewards, and governance. + +Note: +- `CMTAT/` is a git submodule and should be treated as external code. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..05f9ec9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +SnapshotEngine is a Solidity/Hardhat codebase for on-chain ERC-20 snapshots. + +Main point: +- Schedule, reschedule, and execute snapshot times. +- Record historical account balances and total supply at snapshot boundaries. +- Expose snapshot state for downstream on-chain features like dividends, rewards, and governance. + +Note: +- `CMTAT/` is a git submodule and should be treated as external code. + +- Update `CHANGELOG.md` for each new relevant modification. +- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1bbc4..a6ea008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,29 +2,88 @@ Please follow conventions. +## 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/) + +Custom changelog tag: `Dependencies`, `Documentation`, `Testing` + ## Checklist > Before a new release, perform the following tasks -- Code: Update the version name defined in [SnapshotEngine.sol](contracts/SnapshotEngine.sol) +- Code: Update the version name in the `Version` core module, variable VERSION - Run linter > npm run-script lint:all:prettier - Documentation - - Perform a code coverage and update the files in the corresponding directory [./doc/coverage](./doc/coverage) - - Perform an audit with several audit tools (e.g Slither), update the report in the corresponding directory [./doc/audits/](./doc/audits/) + - 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 +## 0.4.0 + +- Dependencies + - Align integration for `CMTAT v3.2.0`. + - Update `CMTATBaseRuleEngine` import path (`1_CMTATBaseRuleEngine.sol` -> `2_CMTATBaseRuleEngine.sol`). + - Update version interface usage from `IERC3643Base` to `IERC3643Version`. + - Update OpenZeppelin dependencies to `@openzeppelin/contracts` and `@openzeppelin/contracts-upgradeable` `5.6.1`. + - Replace full `IERC20` dependency in SnapshotEngine modules with a minimal `IERC20SnapshotCompatible` interface (`balanceOf`, `totalSupply`). +- Documentation + - Clarify and document the `0.3.0` known issue and `0.4.0` resolution for `getNextSnapshots()` arithmetic underflow when no future snapshots remain. + - Document strict snapshot query semantics with exact-time APIs: + - `snapshotExists(time)` + - `snapshotBalanceOfExact(time, tokenHolder)` (reverts if `time` is not scheduled) + - `snapshotTotalSupplyExact(time)` (reverts if `time` is not scheduled) + - Document snapshot materialization observability: + - `SnapshotMaterialized(time, blockNumber)` emitted when `_setCurrentSnapshot()` advances. + - `poke()` can be used by authorized accounts to materialize due snapshots without requiring token transfers. + - Add a second deployment variant using OpenZeppelin `Ownable2Step` (`SnapshotEngineOwnable2Step`) and refactor shared deployment logic into `SnapshotEngineBase` to minimize duplication. + - Add `CMTATStandaloneSnapshot` (standalone/non-proxy deployment) and refactor shared CMTAT+snapshot behavior into `CMTATSnapshotBase` to minimize duplication with `CMTATUpgradeableSnapshot`. + - Update README integration guidance to document the minimal token interface required by SnapshotEngine (`IERC20SnapshotCompatible`). + +- Testing + - Add tests for exact snapshot queries (scheduled vs non-scheduled timestamps and parity with legacy queries on scheduled timestamps). + - Add tests for snapshot materialization event emission. + - Add tests for `poke()` access control and idempotent behavior. + - Add test coverage for `SnapshotUnschedule(time)` emission in `unscheduleSnapshotNotOptimized`. + - Add a dedicated `SnapshotEngineOwnable2Step` test suite and share common snapshot behavior tests across AccessControl and Ownable variants. + - Add a dedicated `CMTATStandaloneSnapshot` test suite reusing the same shared snapshot behavior suites. + - Refactor admin-authorization assertions into a shared helper to avoid duplicated expectations across test modules. + - Refactor snapshot suite registration and CMTAT init params into shared test helpers to reduce duplication across test entrypoints. ## 0.3.0 - 2025-08-27 +Commit: `b5750a0a6f75e73ab00ace6a2cf3e482b1a64352` + - Add deployment version with snapshot for CMTAT - Better code separation - Create new module ` SnapshotUpdateModule` +- Known issue for this release: + - `getNextSnapshots()` may revert (panic `0x11`) in edge cases once the most-recent-past-snapshot optimization branch is enabled/fixed, due to an underflow in future-snapshot array size computation. ## 0.2.0 - 2025-08-25 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..05f9ec9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +SnapshotEngine is a Solidity/Hardhat codebase for on-chain ERC-20 snapshots. + +Main point: +- Schedule, reschedule, and execute snapshot times. +- Record historical account balances and total supply at snapshot boundaries. +- Expose snapshot state for downstream on-chain features like dividends, rewards, and governance. + +Note: +- `CMTAT/` is a git submodule and should be treated as external code. + +- Update `CHANGELOG.md` for each new relevant modification. +- After each implemented feature or fix, provide a one-line GitHub commit message for all changes since the last commit. diff --git a/CMTAT b/CMTAT index 69eecc9..49544f4 160000 --- a/CMTAT +++ b/CMTAT @@ -1 +1 @@ -Subproject commit 69eecc9735ce8ada84fd35801888b05747658939 +Subproject commit 49544f4de1993008acfc9e848d0bf03bd31d8579 diff --git a/FEEDBACK.md b/FEEDBACK.md new file mode 100644 index 0000000..c7287da --- /dev/null +++ b/FEEDBACK.md @@ -0,0 +1,41 @@ +# Code Review Feedback + +## Findings + +### 1. High: `getNextSnapshots()` can revert when current snapshot is already the last scheduled snapshot +- Location: `contracts/library/SnapshotBase.sol:86-90`, `contracts/library/SnapshotBase.sol:431-434` +- What happens: + - `_findScheduledMostRecentPastSnapshot()` can return `(0, currentArraySize)` when `_currentSnapshotIndex + 1 == currentArraySize` and `_currentSnapshotTime != 0`. + - `getNextSnapshots()` then evaluates `indexLowerBound + 1 != $._scheduledSnapshots.length` as `true` (because `currentArraySize + 1 != currentArraySize`). + - It computes `arraySize = length - indexLowerBound - 1`, which underflows and reverts in Solidity 0.8+. +- Impact: + - Read-only calls to `getNextSnapshots()` can unexpectedly revert for valid protocol state (no future snapshots left), breaking integrations and monitoring. +- Recommended fix: + - In `getNextSnapshots()`, use a strict guard before computing `arraySize`: + - `if (indexLowerBound + 1 < $._scheduledSnapshots.length) { ... }` + - Preferred approach: fix this in `getNextSnapshots()` only. + - Do **not** change `_findScheduledMostRecentPastSnapshot()` return semantics unless strictly necessary, because that function is also used by `_setCurrentSnapshot()` and has broader behavioral impact. + +### 2. Medium: Missing `SnapshotUnschedule` event in non-optimized unschedule path +- Location: `contracts/library/SnapshotBase.sol:238-251` +- What happens: + - `_unscheduleLastSnapshot()` emits `SnapshotUnschedule(time)`. + - `_unscheduleSnapshotNotOptimized()` removes a snapshot but does not emit `SnapshotUnschedule`. +- Impact: + - Off-chain indexers/audit logs miss unschedule actions taken through this code path, creating inconsistent event-driven state. +- Recommended fix: + - Emit `SnapshotUnschedule(time)` at the end of `_unscheduleSnapshotNotOptimized()`. + +## Open Questions / Assumptions +- I assumed both unschedule functions are intended to have equivalent externally observable behavior except algorithmic complexity. +- I assumed `getNextSnapshots()` should return an empty array (not revert) when there are no future snapshots. + +## Testing Gaps +- No test currently appears to assert the terminal-index path for `getNextSnapshots()` (current snapshot at the end, no future snapshots). +- No test currently appears to assert `SnapshotUnschedule` emission for `unscheduleSnapshotNotOptimized()`. + +## Verification Notes +- `npx hardhat compile` succeeds. +- `npx hardhat test` could not be completed in this environment due a Mocha reporter/runtime issue: + - `ERR_MOCHA_INVALID_REPORTER` + - `TypeError: spawnSync .../node EPERM` diff --git a/README.md b/README.md index d822a4b..df1f6c8 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,49 @@ > > If you want to use this project, perform your own verification or send an email to [admin@cmta.ch](mailto:admin@cmta.ch). -The **SnapshotEngine** is a smart contract designed to perform on-chain snapshots, making it easier to distribute dividends or other token-based rewards directly on-chain. +The **SnapshotEngine** is a smart contract system designed to perform on-chain snapshots, making it easier to distribute dividends or other token-based rewards directly on-chain. It is intended to work with any standard ERC-20 token (for example, **CMTAT**). If you want to integrate it into another contract—such as one for distributing dividends—you can access balance and state information through the `ISnapshotState` interface, defined in `ISnapshotState.sol`. -The codebase is modular, allowing you to use or extend only the components you need. Thus, instead of using the `SnapshotEngine`as an external contract called by the ERC-20 token, you can integrate the relevant modules directly in the token smart contract. This repository provides an example with CMTAT, see `CMTAT deployment version`. +The codebase is modular, allowing you to use or extend only the components you need. Thus, instead of using the `SnapshotEngine` as an external contract called by the ERC-20 token, you can integrate the relevant modules directly in the token smart contract. This repository provides examples with CMTAT (upgradeable and standalone variants), see `CMTAT deployment version`. -[TOC] +## Repository Notes + +- `CMTAT/` is a git submodule and should be treated as external code. +- Local changes in this repository should not modify code inside the `CMTAT/` submodule. + +## Deployment Variants + +- `SnapshotEngine` (`contracts/deployment/SnapshotEngine.sol`): external engine with AccessControl (`SNAPSHOOTER_ROLE`). +- `SnapshotEngineOwnable2Step` (`contracts/deployment/SnapshotEngineOwnable2Step.sol`): external engine with Ownable2Step authorization. +- `CMTATUpgradeableSnapshot` (`contracts/deployment/CMTATUpgradeableSnapshot.sol`): snapshot logic integrated in CMTAT upgradeable deployment. +- `CMTATStandaloneSnapshot` (`contracts/deployment/CMTATStandaloneSnapshot.sol`): snapshot logic integrated in CMTAT standalone deployment. + +## Table of Contents + +- [When to use it](#when-to-use-it) +- [How to include it](#how-to-include-it) +- [CMTAT deployment version](#cmtat-deployment-version) +- [Schema](#schema) +- [Technical](#technical) +- [Access Control](#access-control) +- [Ethereum API](#ethereum-api) +- [Storage management (ERC-7201)](#storage-management-erc-7201) +- [Usage instructions](#usage-instructions) +- [Generate documentation](#generate-documentation) +- [Security](#security) +- [Further reading](#further-reading) +- [Intellectual property](#intellectual-property) + +## Quick Start + +```bash +npm install +npx hardhat compile +npx hardhat test +``` ### When to use it @@ -30,6 +64,13 @@ In short: While it has been designed for the CMTAT, the `SnapshotEngine` can be used with other ERC-20 contracts to perform on-chain snapshots. +For `SnapshotEngine` deployment, the bound token only needs to expose: + +- `balanceOf(address)` +- `totalSupply()` + +This minimal requirement is captured by `IERC20SnapshotCompatible` in `contracts/interface/IERC20SnapshotCompatible.sol`. + To use it, import in your contract the interface `ISnapshotEngine` which declares the function `operateOnTransfer`. This interface can be found in [CMTAT/contracts/interfaces/engine](https://github.com/CMTA/CMTAT/tree/master/contracts/interfaces/engine) @@ -61,11 +102,14 @@ During each ERC-20 transfer, before updating the balances and total supply, your ### CMTAT deployment version -This repository also contains a CMTAT deployment version with the required snapshot modules integrated called `CMTATUpgradeableSnapshot`. +This repository also contains CMTAT deployment versions with the required snapshot modules integrated: + +- `CMTATUpgradeableSnapshot` for proxy deployment. +- `CMTATStandaloneSnapshot` for non-proxy deployment (initialized through constructor). The CMTAT features are included by inheriting from the CMTAT base contract `CMTATBaseRuleEngine` and overriding the internal `update` function (from OpenZeppelin’s ERC20) to call `_snapshotUpdate`. This internal function is responsible for updating balances and total supply whenever a snapshot is detected. -For each ERC-20 transfer, the `_update` function is called, and a snapshot is taken if required. Since the snapshot logic is integrated directly into the token, there is no need for an external `SnapshotEngine` contract. +For each ERC-20 transfer, the `_update` function is called, and a snapshot is materialized when required. Since the snapshot logic is integrated directly into the token, there is no need for an external `SnapshotEngine` contract. ![CMTATUpgradeableSnapshotUML](./doc/schema/UML/CMTATUpgradeableSnapshotUML.png) @@ -172,6 +216,13 @@ Here are several schema to explain the main functions ## Access Control +Two authorization models are available depending on deployment: + +- `SnapshotEngine`: role-based access control via `SNAPSHOOTER_ROLE`. +- `SnapshotEngineOwnable2Step`: owner-only access via `onlyOwner`. + +Integrated CMTAT snapshot deployments use CMTAT role-based access control for snapshot scheduling functions. + #### RBAC Role list Here is the list of roles and their 32 bytes identifier. @@ -183,9 +234,9 @@ Here is the list of roles and their 32 bytes identifier. ### ERC-20 token bound -The ERC-20 bounds to the Snapshot Engine is set at deployment and can not be changed after that. +The ERC-20 token bound to SnapshotEngine is set at deployment and cannot be changed afterward. -Only the ERC-20 token contract can called the function `operateOnTransfer` defined in the main contract `SnapshotEngine`. +Only the bound token contract can call `operateOnTransfer` in `SnapshotEngine`. ## Ethereum API @@ -361,11 +412,27 @@ Get the next scheduled snapshots that have not yet been created. #### Functions +##### poke() + +```solidity +function poke() public +``` + +Materializes the latest eligible scheduled snapshot (if any), without requiring an ERC-20 transfer. + +**Details:** + +- Useful when no transfer/mint/burn occurs around a scheduled record date. +- Access is restricted by deployment mode: +`SNAPSHOOTER_ROLE` for `SnapshotEngine`, `onlyOwner` for `SnapshotEngineOwnable2Step`. + +------ + ##### scheduleSnapshot(uint256) ```solidity function scheduleSnapshot(uint256 time) -public onlyRole(SNAPSHOOTER_ROLE) +public ``` Schedules a snapshot at the given time (in seconds since epoch). @@ -387,7 +454,7 @@ Schedules a snapshot at the given time (in seconds since epoch). ```solidity function scheduleSnapshotNotOptimized(uint256 time) -public onlyRole(SNAPSHOOTER_ROLE) +public ``` Schedules a snapshot at the given time (non-optimized version). @@ -409,7 +476,7 @@ Schedules a snapshot at the given time (non-optimized version). ```solidity function rescheduleSnapshot(uint256 oldTime,uint256 newTime) -public onlyRole(SNAPSHOOTER_ROLE) +public ``` Reschedules a snapshot from `oldTime` to `newTime`. @@ -432,7 +499,7 @@ Reschedules a snapshot from `oldTime` to `newTime`. ```solidity function unscheduleLastSnapshot(uint256 time) -public onlyRole(SNAPSHOOTER_ROLE) +public ``` Cancels the creation of the last scheduled snapshot at the given time. @@ -454,7 +521,7 @@ Cancels the creation of the last scheduled snapshot at the given time. ```solidity function unscheduleSnapshotNotOptimized(uint256 time) -public onlyRole(SNAPSHOOTER_ROLE) +public ``` Cancels the creation of a scheduled snapshot at the given time (non-optimized version). @@ -486,7 +553,7 @@ Cancels the creation of a scheduled snapshot at the given time (non-optimized ve ```solidity function snapshotBalanceOf(uint256 time,address tokenHolder) -external view returns (uint256 tokenHolderBalance); +public view returns (uint256 tokenHolderBalance); ``` Gets the balance of a specific account at the snapshot corresponding to a given timestamp. @@ -506,12 +573,26 @@ Gets the balance of a specific account at the snapshot corresponding to a given ------ +##### snapshotBalanceOfExact(uint256, address) -> (uint256) + +```solidity +function snapshotBalanceOfExact(uint256 time, address tokenHolder) +public view returns (uint256 tokenHolderBalance); +``` + +Gets the balance at an exact scheduled snapshot timestamp. + +**Details:** + +- Reverts with `SnapshotEngine_SnapshotNotFound` if `time` is not an exact scheduled snapshot. + +------ + ##### snapshotTotalSupply(uint256) -> (uint256) ```solidity function snapshotTotalSupply(uint256 time) -public view override(ISnapshotState) -returns (uint256 totalSupply) +public view returns (uint256 totalSupply) ``` Gets the total token supply at the snapshot corresponding to a given timestamp. @@ -522,6 +603,21 @@ Gets the total token supply at the snapshot corresponding to a given timestamp. | ---- | ------- | ------------------------------------------------ | | time | uint256 | The timestamp identifying the snapshot to query. | +------ + +##### snapshotTotalSupplyExact(uint256) -> (uint256) + +```solidity +function snapshotTotalSupplyExact(uint256 time) +public view returns (uint256 totalSupply); +``` + +Gets the total supply at an exact scheduled snapshot timestamp. + +**Details:** + +- Reverts with `SnapshotEngine_SnapshotNotFound` if `time` is not an exact scheduled snapshot. + **Return Values:** | Name | Type | Description | @@ -534,8 +630,7 @@ Gets the total token supply at the snapshot corresponding to a given timestamp. ```solidity function snapshotInfo(uint256 time, address tokenHolder) -public view override(ISnapshotState) -returns (uint256 tokenHolderBalance, uint256 totalSupply) +public view returns (uint256 tokenHolderBalance, uint256 totalSupply) ``` Retrieves both an account's balance and the total supply at the snapshot for a given timestamp in a single call. @@ -560,8 +655,7 @@ Retrieves both an account's balance and the total supply at the snapshot for a g ```solidity function snapshotInfoBatch(uint256 time, address[] calldata addresses) -public view override(ISnapshotState) -returns (uint256[] memory tokenHolderBalances, uint256 totalSupply) +public view returns (uint256[] memory tokenHolderBalances, uint256 totalSupply) ``` Retrieves balances of multiple accounts and the total supply at a snapshot for a given timestamp in a single call. @@ -586,8 +680,7 @@ Retrieves balances of multiple accounts and the total supply at a snapshot for a ```solidity function snapshotInfoBatch(uint256[] calldata times, address[] calldata addresses) -public view override(ISnapshotState) -returns (uint256[][] memory tokenHolderBalances, uint256[] memory totalSupply) +public view returns (uint256[][] memory tokenHolderBalances, uint256[] memory totalSupply) ``` Retrieves balances of multiple accounts at multiple snapshots, as well as the total supply at each snapshot. @@ -614,7 +707,7 @@ Retrieves balances of multiple accounts at multiple snapshots, as well as the to ## Storage management (ERC-7201) -While SnapshotEngine can not be deployed with a proxy, modules implement [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) to allow them to be directly used by a potential CMTAT deployment version. +While SnapshotEngine cannot be deployed with a proxy, modules implement [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) to allow direct reuse by CMTAT deployment variants. ## Usage instructions @@ -633,13 +726,13 @@ are the latest ones that we tested: - Solidity [v0.8.30](https://docs.soliditylang.org/en/v0.8.30/) - - CMTAT [v3.0.0](https://github.com/CMTA/CMTAT/releases/tag/v3.0.0) + - CMTAT [v3.2.0](https://github.com/CMTA/CMTAT/releases/tag/v3.2.0) - OpenZeppelin - - OpenZeppelin Contracts (Node.js module) [v5.4.0](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.4.0) + - OpenZeppelin Contracts (Node.js module) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.6.1) - - OpenZeppelin Contracts Upgradeable (Node.js module) [v5.4.0](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.4.0) (to compile CMTAT) + - OpenZeppelin Contracts Upgradeable (Node.js module) [v5.6.1](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.6.1) (to compile CMTAT) @@ -696,7 +789,7 @@ The script calls the plugin [hardhat-contract-sizer](https://www.npmjs.com/packa #### Testing -Tests are written in JavaScript by using [web3js](https://web3js.readthedocs.io/en/v1.10.0/) and run **only** with Hardhat as follows: +Tests are written in JavaScript using Hardhat + Ethers and run **only** with Hardhat as follows: `npx hardhat test` diff --git a/contracts/CMTATUpgradeableSnapshot.sol b/contracts/CMTATUpgradeableSnapshot.sol deleted file mode 100644 index 2f4c140..0000000 --- a/contracts/CMTATUpgradeableSnapshot.sol +++ /dev/null @@ -1,85 +0,0 @@ -//SPDX-License-Identifier: MPL-2.0 - -pragma solidity ^0.8.20; - -/* ==== OpenZeppelin === */ -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -/* ==== Modules === */ -import {SnapshotSchedulerModule} from "./modules/SnapshotSchedulerModule.sol"; -import {SnapshotUpdateModule} from "./modules/SnapshotUpdateModule.sol"; -/* ==== CMTAT === */ -import {CMTATBaseCommon, ERC20Upgradeable} from "../CMTAT/contracts/modules/0_CMTATBaseCommon.sol"; -import {CMTATBaseRuleEngine} from "../CMTAT/contracts/modules/1_CMTATBaseRuleEngine.sol"; -/* ==== Interfaces and library === */ -import {ISnapshotState} from "./interface/ISnapshotState.sol"; -import {SnapshotStateInternal} from "./library/SnapshotStateInternal.sol"; -contract CMTATUpgradeableSnapshot is SnapshotUpdateModule, SnapshotSchedulerModule, CMTATBaseRuleEngine, SnapshotStateInternal, ISnapshotState{ - /// @custom:oz-upgrades-unsafe-allow constructor - constructor() { - // Disable the possibility to initialize the implementation - _disableInitializers(); - } - - /*////////////////////////////////////////////////////////////// - PUBLIC/EXTERNAL FUNCTIONS - //////////////////////////////////////////////////////////////*/ - - /*////////////////////////////////////////////////////////////// - VIEW FUNCTIONS - //////////////////////////////////////////////////////////////*/ - /** - * @inheritdoc ISnapshotState - */ - function snapshotInfo(uint256 time, address tokenHolder) public view override(ISnapshotState) returns (uint256 tokenHolderBalance, uint256 totalSupply) { - (tokenHolderBalance, totalSupply) = _snapshotInfo(IERC20(IERC20(address(this))), time, tokenHolder); - } - - /** - * @inheritdoc ISnapshotState - */ - function snapshotInfoBatch(uint256 time, address[] calldata addresses) public view override(ISnapshotState) returns (uint256[] memory tokenHolderBalances, uint256 totalSupply) { - (tokenHolderBalances, totalSupply) = _snapshotInfoBatch(IERC20(address(this)), time, addresses); - } - - /** - * @inheritdoc ISnapshotState - */ - function snapshotInfoBatch(uint256[] calldata times, address[] calldata addresses) public view override(ISnapshotState) returns (uint256[][] memory tokenHolderBalances, uint256[] memory totalSupply) { - (tokenHolderBalances, totalSupply) = _snapshotInfoBatch(IERC20(address(this)), times, addresses); - } - - /** - * @inheritdoc ISnapshotState - */ - function snapshotBalanceOf( - uint256 time, - address tokenHolder - ) public view override(ISnapshotState) returns (uint256) { - return _snapshotBalanceOf(IERC20(address(this)), time, tokenHolder); - } - - /** - * @inheritdoc ISnapshotState - */ - function snapshotTotalSupply(uint256 time) public view override(ISnapshotState) returns (uint256 totalSupply) { - return _snapshotTotalSupply(IERC20(address(this)), time); - } - - /*////////////////////////////////////////////////////////////// - ERC-20 ENTRY POINT - //////////////////////////////////////////////////////////////*/ - function _update( - address from, - address to, - uint256 amount - ) internal override(CMTATBaseCommon){ - SnapshotUpdateModule._snapshotUpdate(from, to, balanceOf(from), balanceOf(to), totalSupply()); - CMTATBaseCommon._update(from, to, amount); - - } - - - function _authorizeSnapshot() internal virtual override onlyRole(SNAPSHOOTER_ROLE){ - // Nothing to do - } -} diff --git a/contracts/SnapshotEngine.sol b/contracts/SnapshotEngine.sol deleted file mode 100644 index 17378da..0000000 --- a/contracts/SnapshotEngine.sol +++ /dev/null @@ -1,68 +0,0 @@ -//SPDX-License-Identifier: MPL-2.0 - -pragma solidity ^0.8.20; - -/* ==== OpenZeppelin === */ -import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -/* ==== CMTAT === */ -import {ISnapshotEngine} from "../CMTAT/contracts/interfaces/engine/ISnapshotEngine.sol"; -/* ==== Modules === */ -import {SnapshotSchedulerModule} from "./modules/SnapshotSchedulerModule.sol"; -import {SnapshotUpdateModule} from "./modules/SnapshotUpdateModule.sol"; -import {SnapshotStateModule} from "./modules/SnapshotStateModule.sol"; -import {VersionModule} from "./modules/VersionModule.sol"; -/* ==== Interfaces and library === */ -import {Errors} from "./library/Errors.sol"; -contract SnapshotEngine is SnapshotStateModule, SnapshotUpdateModule, SnapshotSchedulerModule, VersionModule, AccessControl, ISnapshotEngine { - /* ============ State Variables ============ */ - bytes32 public constant SNAPSHOOTER_ROLE = keccak256("SNAPSHOOTER_ROLE"); - /* ==== Modifier === */ - modifier onlyBoundToken() { - if (_msgSender() != address(erc20)) { - revert Errors.SnapshotEngine_UnauthorizedCaller(); - } - _; - } - - /* ============ Constructor ============ */ - constructor(IERC20 erc20_, address admin) { - require(address(erc20_) != address(0), Errors.SnapshotEngine_AddressZeroNotAllowedForERC20()); - require(admin != address(0), Errors.SnapshotEngine_AddressZeroNotAllowedForAdmin()); - erc20 = erc20_; - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } - - /*////////////////////////////////////////////////////////////// - PUBLIC/EXTERNAL FUNCTIONS - //////////////////////////////////////////////////////////////*/ - /** - * @dev Returns `true` if `account` has been granted `role`. - */ - function hasRole( - bytes32 role, - address account - ) public view virtual override(AccessControl) returns (bool) { - // The Default Admin has all roles - if (AccessControl.hasRole(DEFAULT_ADMIN_ROLE, account)) { - return true; - } else { - return AccessControl.hasRole(role, account); - } - } - - /*////////////////////////////////////////////////////////////// - ERC-20 ENTRY POINT - //////////////////////////////////////////////////////////////*/ - /** - * @inheritdoc ISnapshotEngine - */ - function operateOnTransfer(address from, address to, uint256 balanceFrom, uint256 balanceTo, uint256 totalSupply) public override onlyBoundToken() { - _snapshotUpdate(from, to, balanceFrom, balanceTo, totalSupply); - } - - - function _authorizeSnapshot() internal virtual override onlyRole(SNAPSHOOTER_ROLE){ - // Nothing to do - } -} diff --git a/contracts/base/CMTATSnapshotBase.sol b/contracts/base/CMTATSnapshotBase.sol new file mode 100644 index 0000000..572fc8a --- /dev/null +++ b/contracts/base/CMTATSnapshotBase.sol @@ -0,0 +1,103 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +/* ==== Modules === */ +import {SnapshotSchedulerModule} from "../modules/SnapshotSchedulerModule.sol"; +import {SnapshotUpdateModule} from "../modules/SnapshotUpdateModule.sol"; +/* ==== CMTAT === */ +import {CMTATBaseCommon} from "../../CMTAT/contracts/modules/0_CMTATBaseCommon.sol"; +import {CMTATBaseRuleEngine} from "../../CMTAT/contracts/modules/2_CMTATBaseRuleEngine.sol"; +/* ==== Interfaces and library === */ +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; +import {ISnapshotState} from "../interface/ISnapshotState.sol"; +import {SnapshotStateInternal} from "../library/SnapshotStateInternal.sol"; + +abstract contract CMTATSnapshotBase is + SnapshotUpdateModule, + SnapshotSchedulerModule, + CMTATBaseRuleEngine, + SnapshotStateInternal, + ISnapshotState +{ + /*////////////////////////////////////////////////////////////// + VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /** + * @inheritdoc ISnapshotState + */ + function snapshotExists(uint256 time) public view virtual override(ISnapshotState) returns (bool) { + return _isScheduledSnapshot(time); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotInfo(uint256 time, address tokenHolder) public view virtual override(ISnapshotState) returns (uint256 tokenHolderBalance, uint256 totalSupply) { + (tokenHolderBalance, totalSupply) = _snapshotInfo(IERC20SnapshotCompatible(address(this)), time, tokenHolder); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotInfoBatch(uint256 time, address[] calldata addresses) public view virtual override(ISnapshotState) returns (uint256[] memory tokenHolderBalances, uint256 totalSupply) { + (tokenHolderBalances, totalSupply) = _snapshotInfoBatch(IERC20SnapshotCompatible(address(this)), time, addresses); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotInfoBatch(uint256[] calldata times, address[] calldata addresses) public view virtual override(ISnapshotState) returns (uint256[][] memory tokenHolderBalances, uint256[] memory totalSupply) { + (tokenHolderBalances, totalSupply) = _snapshotInfoBatch(IERC20SnapshotCompatible(address(this)), times, addresses); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotBalanceOf( + uint256 time, + address tokenHolder + ) public view virtual override(ISnapshotState) returns (uint256) { + return _snapshotBalanceOf(IERC20SnapshotCompatible(address(this)), time, tokenHolder); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotBalanceOfExact( + uint256 time, + address tokenHolder + ) public view virtual override(ISnapshotState) returns (uint256) { + return _snapshotBalanceOfExact(IERC20SnapshotCompatible(address(this)), time, tokenHolder); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotTotalSupply(uint256 time) public view virtual override(ISnapshotState) returns (uint256 totalSupply) { + return _snapshotTotalSupply(IERC20SnapshotCompatible(address(this)), time); + } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotTotalSupplyExact(uint256 time) public view virtual override(ISnapshotState) returns (uint256 totalSupply) { + return _snapshotTotalSupplyExact(IERC20SnapshotCompatible(address(this)), time); + } + + /*////////////////////////////////////////////////////////////// + ERC-20 ENTRY POINT + //////////////////////////////////////////////////////////////*/ + function _update( + address from, + address to, + uint256 amount + ) internal virtual override(CMTATBaseCommon) { + SnapshotUpdateModule._snapshotUpdate(from, to, balanceOf(from), balanceOf(to), totalSupply()); + CMTATBaseCommon._update(from, to, amount); + } + + function _authorizeSnapshot() internal virtual override onlyRole(SNAPSHOOTER_ROLE) { + // Nothing to do + } +} diff --git a/contracts/base/SnapshotEngineBase.sol b/contracts/base/SnapshotEngineBase.sol new file mode 100644 index 0000000..268a7c1 --- /dev/null +++ b/contracts/base/SnapshotEngineBase.sol @@ -0,0 +1,45 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +/* ==== CMTAT === */ +import {ISnapshotEngine} from "../../CMTAT/contracts/interfaces/engine/ISnapshotEngine.sol"; +/* ==== Modules === */ +import {SnapshotSchedulerModule} from "../modules/SnapshotSchedulerModule.sol"; +import {SnapshotUpdateModule} from "../modules/SnapshotUpdateModule.sol"; +import {SnapshotStateModule} from "../modules/SnapshotStateModule.sol"; +import {VersionModule} from "../modules/VersionModule.sol"; +/* ==== Interfaces and library === */ +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; +import {Errors} from "../library/Errors.sol"; + +abstract contract SnapshotEngineBase is SnapshotStateModule, SnapshotUpdateModule, SnapshotSchedulerModule, VersionModule, ISnapshotEngine { + /* ==== Modifier === */ + modifier onlyBoundToken() { + if (msg.sender != address(erc20)) { + revert Errors.SnapshotEngine_UnauthorizedCaller(); + } + _; + } + + constructor(IERC20SnapshotCompatible erc20_) { + require(address(erc20_) != address(0), Errors.SnapshotEngine_AddressZeroNotAllowedForERC20()); + erc20 = erc20_; + } + + /*////////////////////////////////////////////////////////////// + ERC-20 ENTRY POINT + //////////////////////////////////////////////////////////////*/ + /** + * @inheritdoc ISnapshotEngine + */ + function operateOnTransfer( + address from, + address to, + uint256 balanceFrom, + uint256 balanceTo, + uint256 totalSupply + ) public override onlyBoundToken { + _snapshotUpdate(from, to, balanceFrom, balanceTo, totalSupply); + } +} diff --git a/contracts/deployment/CMTATStandaloneSnapshot.sol b/contracts/deployment/CMTATStandaloneSnapshot.sol new file mode 100644 index 0000000..6e7efa9 --- /dev/null +++ b/contracts/deployment/CMTATStandaloneSnapshot.sol @@ -0,0 +1,34 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +import {CMTATSnapshotBase} from "../base/CMTATSnapshotBase.sol"; +import {ICMTATConstructor} from "../../CMTAT/contracts/interfaces/technical/ICMTATConstructor.sol"; + +/** +* @title CMTAT version with Snapshot for a standalone deployment (without proxy) +*/ +contract CMTATStandaloneSnapshot is CMTATSnapshotBase { + /** + * @notice Contract version for standalone deployment with snapshot support + * @param admin address of the admin of contract (Access Control) + * @param ERC20Attributes_ ERC20 name, symbol and decimals + * @param extraInformationAttributes_ tokenId, terms, information + * @param engines_ external contract + */ + /// @custom:oz-upgrades-unsafe-allow constructor + constructor( + address admin, + ICMTATConstructor.ERC20Attributes memory ERC20Attributes_, + ICMTATConstructor.ExtraInformationAttributes memory extraInformationAttributes_, + ICMTATConstructor.Engine memory engines_ + ) { + // Initialize the contract to avoid front-running + initialize( + admin, + ERC20Attributes_, + extraInformationAttributes_, + engines_ + ); + } +} diff --git a/contracts/deployment/CMTATUpgradeableSnapshot.sol b/contracts/deployment/CMTATUpgradeableSnapshot.sol new file mode 100644 index 0000000..fe2d47a --- /dev/null +++ b/contracts/deployment/CMTATUpgradeableSnapshot.sol @@ -0,0 +1,13 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +import {CMTATSnapshotBase} from "../base/CMTATSnapshotBase.sol"; + +contract CMTATUpgradeableSnapshot is CMTATSnapshotBase { + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + // Disable the possibility to initialize the implementation + _disableInitializers(); + } +} diff --git a/contracts/deployment/SnapshotEngine.sol b/contracts/deployment/SnapshotEngine.sol new file mode 100644 index 0000000..3ce18ef --- /dev/null +++ b/contracts/deployment/SnapshotEngine.sol @@ -0,0 +1,44 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +/* ==== OpenZeppelin === */ +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +/* ==== Deployment === */ +import {SnapshotEngineBase} from "../base/SnapshotEngineBase.sol"; +/* ==== Interfaces and library === */ +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; +import {Errors} from "../library/Errors.sol"; + +contract SnapshotEngine is SnapshotEngineBase, AccessControl { + /* ============ State Variables ============ */ + bytes32 public constant SNAPSHOOTER_ROLE = keccak256("SNAPSHOOTER_ROLE"); + + /* ============ Constructor ============ */ + constructor(IERC20SnapshotCompatible erc20_, address admin) SnapshotEngineBase(erc20_) { + require(admin != address(0), Errors.SnapshotEngine_AddressZeroNotAllowedForAdmin()); + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /*////////////////////////////////////////////////////////////// + PUBLIC/EXTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole( + bytes32 role, + address account + ) public view virtual override(AccessControl) returns (bool) { + // The Default Admin has all roles + if (AccessControl.hasRole(DEFAULT_ADMIN_ROLE, account)) { + return true; + } else { + return AccessControl.hasRole(role, account); + } + } + + function _authorizeSnapshot() internal virtual override onlyRole(SNAPSHOOTER_ROLE) { + // Nothing to do + } +} diff --git a/contracts/deployment/SnapshotEngineOwnable2Step.sol b/contracts/deployment/SnapshotEngineOwnable2Step.sol new file mode 100644 index 0000000..786f44b --- /dev/null +++ b/contracts/deployment/SnapshotEngineOwnable2Step.sol @@ -0,0 +1,21 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +/* ==== OpenZeppelin === */ +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol"; +/* ==== Deployment === */ +import {SnapshotEngineBase} from "../base/SnapshotEngineBase.sol"; +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; + +contract SnapshotEngineOwnable2Step is SnapshotEngineBase, Ownable2Step { + /* ============ Constructor ============ */ + constructor(IERC20SnapshotCompatible erc20_, address admin) SnapshotEngineBase(erc20_) Ownable(admin) { + // Nothing to do + } + + function _authorizeSnapshot() internal virtual override onlyOwner { + // Nothing to do + } +} diff --git a/contracts/interface/IERC20SnapshotCompatible.sol b/contracts/interface/IERC20SnapshotCompatible.sol new file mode 100644 index 0000000..a3b9540 --- /dev/null +++ b/contracts/interface/IERC20SnapshotCompatible.sol @@ -0,0 +1,13 @@ +//SPDX-License-Identifier: MPL-2.0 + +pragma solidity ^0.8.20; + +/** + * @title IERC20SnapshotCompatible + * @notice Minimal token interface required by SnapshotEngine state queries. + */ +interface IERC20SnapshotCompatible { + function balanceOf(address account) external view returns (uint256); + + function totalSupply() external view returns (uint256); +} diff --git a/contracts/interface/ISnapshotBase.sol b/contracts/interface/ISnapshotBase.sol index f52e718..e2c5e4e 100644 --- a/contracts/interface/ISnapshotBase.sol +++ b/contracts/interface/ISnapshotBase.sol @@ -19,6 +19,13 @@ interface ISnapshotBase { */ event SnapshotUnschedule(uint256 indexed time); + /** + * @notice Emitted when a scheduled snapshot becomes the current materialized snapshot. + * @param time The snapshot timestamp that was materialized. + * @param blockNumber The block number at which the materialization occurred. + */ + event SnapshotMaterialized(uint256 indexed time, uint256 indexed blockNumber); + /* ============ Erros ============ */ /** * @notice Thrown when attempting to schedule a snapshot at a time earlier than the current block timestamp. diff --git a/contracts/interface/ISnapshotScheduler.sol b/contracts/interface/ISnapshotScheduler.sol index 7764b7d..3dfe566 100644 --- a/contracts/interface/ISnapshotScheduler.sol +++ b/contracts/interface/ISnapshotScheduler.sol @@ -4,6 +4,12 @@ pragma solidity ^0.8.0; /// @title ISnapshotScheduler /// @notice Interface for scheduling, rescheduling, and canceling snapshots interface ISnapshotScheduler { + /** + * @notice Materialize the latest eligible scheduled snapshot (if any). + * @dev Access restricted to accounts with SNAPSHOOTER_ROLE. + */ + function poke() external; + /** * @notice Schedule a snapshot at the given time specified as a number of seconds since epoch. * @dev The time cannot be before the latest scheduled but not yet created snapshot. @@ -44,4 +50,4 @@ interface ISnapshotScheduler { * @param time The scheduled time of the snapshot to cancel. */ function unscheduleSnapshotNotOptimized(uint256 time) external; -} \ No newline at end of file +} diff --git a/contracts/interface/ISnapshotState.sol b/contracts/interface/ISnapshotState.sol index b503a67..3b69fa0 100644 --- a/contracts/interface/ISnapshotState.sol +++ b/contracts/interface/ISnapshotState.sol @@ -7,6 +7,12 @@ pragma solidity ^0.8.20; * @notice Minimal interface for a contract (e.g. SnapshotEngine or CMTAT) that supports querying historical balances and total supply using snapshots. */ interface ISnapshotState { + /** + * @notice Returns true if `time` is an exact scheduled snapshot timestamp. + * @param time The timestamp to check. + */ + function snapshotExists(uint256 time) external view returns (bool); + /** * @notice Get the balance of a specific tokenHolder at the snapshot corresponding to a given timestamp. * @param time The timestamp identifying the snapshot to query. @@ -14,6 +20,14 @@ interface ISnapshotState { * @return tokenHolderBalance The recorded balance at the snapshot, or the current balance if no snapshot exists for that timestamp. */ function snapshotBalanceOf(uint256 time,address tokenHolder) external view returns (uint256 tokenHolderBalance); + + /** + * @notice Get the balance of a specific tokenHolder at an exact scheduled snapshot timestamp. + * @dev Reverts if `time` is not an exact scheduled snapshot timestamp. + * @param time The exact snapshot timestamp to query. + * @param tokenHolder The address whose balance is being requested. + */ + function snapshotBalanceOfExact(uint256 time,address tokenHolder) external view returns (uint256 tokenHolderBalance); /** * @notice Get the total token supply at the snapshot corresponding to a given timestamp. @@ -22,6 +36,13 @@ interface ISnapshotState { */ function snapshotTotalSupply(uint256 time) external view returns (uint256 totalSupply); + /** + * @notice Get the total token supply at an exact scheduled snapshot timestamp. + * @dev Reverts if `time` is not an exact scheduled snapshot timestamp. + * @param time The exact snapshot timestamp to query. + */ + function snapshotTotalSupplyExact(uint256 time) external view returns (uint256 totalSupply); + /** * @notice Retrieve both an account's balance and the total supply at the snapshot for a given timestamp in a single call. * @param time The timestamp identifying the snapshot to query. diff --git a/contracts/library/SnapshotBase.sol b/contracts/library/SnapshotBase.sol index 7035104..eb815d0 100644 --- a/contracts/library/SnapshotBase.sol +++ b/contracts/library/SnapshotBase.sol @@ -83,7 +83,7 @@ abstract contract SnapshotBase is ISnapshotBase { return $._scheduledSnapshots; } else { // There are snapshots situated in the futur - if (indexLowerBound + 1 != $._scheduledSnapshots.length) { + if (indexLowerBound + 1 < $._scheduledSnapshots.length) { // All next snapshots are located after the snapshot specified by indexLowerBound uint256 arraySize = $._scheduledSnapshots.length - indexLowerBound - @@ -248,6 +248,7 @@ abstract contract SnapshotBase is ISnapshotBase { $._scheduledSnapshots = scheduledSnapshotLocal; // pop is only available for storage array $._scheduledSnapshots.pop(); + emit SnapshotUnschedule(time); } @@ -258,13 +259,15 @@ abstract contract SnapshotBase is ISnapshotBase { */ function _setCurrentSnapshot() internal { SnapshotBaseStorage storage $ = _getSnapshotBaseStorage(); + uint256 previousSnapshotTime = $._currentSnapshotTime; ( uint256 scheduleSnapshotTime, uint256 scheduleSnapshotIndex ) = _findScheduledMostRecentPastSnapshot($); - if (scheduleSnapshotTime > 0) { + if (scheduleSnapshotTime > previousSnapshotTime) { $._currentSnapshotTime = scheduleSnapshotTime; $._currentSnapshotIndex = scheduleSnapshotIndex; + emit SnapshotMaterialized(scheduleSnapshotTime, block.number); } } @@ -319,6 +322,24 @@ abstract contract SnapshotBase is ISnapshotBase { return snapshotted ? value : totalSupply; } + /** + * @dev Returns true when `time` is an exact scheduled snapshot timestamp. + */ + function _snapshotExists(uint256 time) internal view returns (bool) { + SnapshotBaseStorage storage $ = _getSnapshotBaseStorage(); + (bool isFound, ) = _findScheduledSnapshotIndex($, time); + return isFound; + } + + /** + * @dev Reverts when `time` is not an exact scheduled snapshot timestamp. + */ + function _requireSnapshotExists(uint256 time) internal view { + if (!_snapshotExists(time)) { + revert SnapshotEngine_SnapshotNotFound(); + } + } + /*////////////////////////////////////////////////////////////// PRIVATE FUNCTIONS @@ -428,7 +449,7 @@ abstract contract SnapshotBase is ISnapshotBase { // no snapshot or the current snapshot already points on the last snapshot if ( currentArraySize == 0 || - (($._currentSnapshotIndex + 1 == currentArraySize) && (time != 0)) + (($._currentSnapshotIndex + 1 == currentArraySize) && ($._currentSnapshotTime != 0)) ) { return (0, currentArraySize); } diff --git a/contracts/library/SnapshotStateInternal.sol b/contracts/library/SnapshotStateInternal.sol index 0fe2d0d..dae037e 100644 --- a/contracts/library/SnapshotStateInternal.sol +++ b/contracts/library/SnapshotStateInternal.sol @@ -3,20 +3,19 @@ pragma solidity ^0.8.20; import {SnapshotBase} from "./SnapshotBase.sol"; -/* ==== OpenZeppelin === */ -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; abstract contract SnapshotStateInternal is SnapshotBase { /*////////////////////////////////////////////////////////////// INTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ - function _snapshotInfoBatch(IERC20 erc20_, uint256[] calldata times, address[] calldata addresses) internal view returns (uint256[][] memory ownerBalances, uint256[] memory totalSupply) { + function _snapshotInfoBatch(IERC20SnapshotCompatible erc20_, uint256[] calldata times, address[] calldata addresses) internal view returns (uint256[][] memory ownerBalances, uint256[] memory totalSupply) { ownerBalances = new uint256[][](times.length); totalSupply = new uint256[](times.length); for(uint256 iT = 0; iT < times.length; ++iT){ (ownerBalances[iT], totalSupply[iT]) = _snapshotInfoBatch(erc20_, times[iT],addresses); } } - function _snapshotInfoBatch(IERC20 erc20_, uint256 time, address[] calldata addresses) internal view returns (uint256[] memory ownerBalances, uint256 totalSupply) { + function _snapshotInfoBatch(IERC20SnapshotCompatible erc20_, uint256 time, address[] calldata addresses) internal view returns (uint256[] memory ownerBalances, uint256 totalSupply) { ownerBalances = new uint256[](addresses.length); for(uint256 i = 0; i < addresses.length; ++i){ ownerBalances[i] = _snapshotBalanceOf(erc20_, time, addresses[i]); @@ -24,19 +23,37 @@ abstract contract SnapshotStateInternal is SnapshotBase { totalSupply = _snapshotTotalSupply(erc20_, time); } - function _snapshotInfo( IERC20 erc20_, uint256 time, address owner) internal view returns (uint256 ownerBalance, uint256 totalSupply) { + function _snapshotInfo( IERC20SnapshotCompatible erc20_, uint256 time, address owner) internal view returns (uint256 ownerBalance, uint256 totalSupply) { ownerBalance = _snapshotBalanceOf(erc20_, time, owner); totalSupply = _snapshotTotalSupply(erc20_, time); } + function _isScheduledSnapshot(uint256 time) internal view returns (bool) { + return SnapshotBase._snapshotExists(time); + } + function _snapshotBalanceOf( - IERC20 erc20_, + IERC20SnapshotCompatible erc20_, uint256 time, address owner ) internal view returns (uint256) { return SnapshotBase._snapshotBalanceOf(time, owner, erc20_.balanceOf(owner)); } - function _snapshotTotalSupply(IERC20 erc20_, uint256 time) internal view returns (uint256) { + function _snapshotTotalSupply(IERC20SnapshotCompatible erc20_, uint256 time) internal view returns (uint256) { + return SnapshotBase._snapshotTotalSupply(time, erc20_.totalSupply()); + } + + function _snapshotBalanceOfExact( + IERC20SnapshotCompatible erc20_, + uint256 time, + address owner + ) internal view returns (uint256) { + SnapshotBase._requireSnapshotExists(time); + return SnapshotBase._snapshotBalanceOf(time, owner, erc20_.balanceOf(owner)); + } + + function _snapshotTotalSupplyExact(IERC20SnapshotCompatible erc20_, uint256 time) internal view returns (uint256) { + SnapshotBase._requireSnapshotExists(time); return SnapshotBase._snapshotTotalSupply(time, erc20_.totalSupply()); } } diff --git a/contracts/modules/SnapshotSchedulerModule.sol b/contracts/modules/SnapshotSchedulerModule.sol index c475b25..b5a729a 100644 --- a/contracts/modules/SnapshotSchedulerModule.sol +++ b/contracts/modules/SnapshotSchedulerModule.sol @@ -9,6 +9,14 @@ abstract contract SnapshotSchedulerModule is SnapshotBase, ISnapshotScheduler { /*////////////////////////////////////////////////////////////// PUBLIC/EXTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ + /** + * @inheritdoc ISnapshotScheduler + */ + function poke() public virtual { + _authorizeSnapshot(); + SnapshotBase._setCurrentSnapshot(); + } + /** * @inheritdoc ISnapshotScheduler */ diff --git a/contracts/modules/SnapshotStateModule.sol b/contracts/modules/SnapshotStateModule.sol index be2ed1b..7216996 100644 --- a/contracts/modules/SnapshotStateModule.sol +++ b/contracts/modules/SnapshotStateModule.sol @@ -1,15 +1,14 @@ //SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; -/* ==== OpenZeppelin === */ -import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; /* ==== Interfaces and library === */ +import {IERC20SnapshotCompatible} from "../interface/IERC20SnapshotCompatible.sol"; import {SnapshotStateInternal} from "../library/SnapshotStateInternal.sol"; import {ISnapshotState} from "../interface/ISnapshotState.sol"; abstract contract SnapshotStateModule is SnapshotStateInternal, ISnapshotState { - IERC20 internal immutable erc20; + IERC20SnapshotCompatible internal immutable erc20; /*////////////////////////////////////////////////////////////// PUBLIC/EXTERNAL FUNCTIONS @@ -18,6 +17,13 @@ abstract contract SnapshotStateModule is SnapshotStateInternal, ISnapshotState /*////////////////////////////////////////////////////////////// VIEW FUNCTIONS //////////////////////////////////////////////////////////////*/ + /** + * @inheritdoc ISnapshotState + */ + function snapshotExists(uint256 time) public view override(ISnapshotState) returns (bool) { + return _isScheduledSnapshot(time); + } + /** * @inheritdoc ISnapshotState */ @@ -49,10 +55,27 @@ abstract contract SnapshotStateModule is SnapshotStateInternal, ISnapshotState return _snapshotBalanceOf(erc20, time, tokenHolder); } + /** + * @inheritdoc ISnapshotState + */ + function snapshotBalanceOfExact( + uint256 time, + address tokenHolder + ) public view override(ISnapshotState) returns (uint256) { + return _snapshotBalanceOfExact(erc20, time, tokenHolder); + } + /** * @inheritdoc ISnapshotState */ function snapshotTotalSupply(uint256 time) public view override(ISnapshotState) returns (uint256 totalSupply) { return _snapshotTotalSupply(erc20, time); } + + /** + * @inheritdoc ISnapshotState + */ + function snapshotTotalSupplyExact(uint256 time) public view override(ISnapshotState) returns (uint256 totalSupply) { + return _snapshotTotalSupplyExact(erc20, time); + } } diff --git a/contracts/modules/VersionModule.sol b/contracts/modules/VersionModule.sol index 762fa1e..6b353d4 100644 --- a/contracts/modules/VersionModule.sol +++ b/contracts/modules/VersionModule.sol @@ -3,9 +3,9 @@ pragma solidity ^0.8.20; /* ==== CMTAT === */ -import {IERC3643Base} from "../../CMTAT/contracts/interfaces/tokenization/IERC3643Partial.sol"; +import {IERC3643Version} from "../../CMTAT/contracts/interfaces/tokenization/IERC3643Partial.sol"; -abstract contract VersionModule is IERC3643Base { +abstract contract VersionModule is IERC3643Version { /* ============ State Variables ============ */ /** * @dev @@ -18,15 +18,15 @@ abstract contract VersionModule is IERC3643Base { PUBLIC/EXTERNAL FUNCTIONS //////////////////////////////////////////////////////////////*/ /** - * @inheritdoc IERC3643Base + * @inheritdoc IERC3643Version */ function version() public view virtual - override(IERC3643Base) + override(IERC3643Version) returns (string memory version_) { return VERSION; } -} \ No newline at end of file +} diff --git a/doc/audits/aderyn-report.md b/doc/audits/tools/v0.3.0/aderyn-report.md similarity index 100% rename from doc/audits/aderyn-report.md rename to doc/audits/tools/v0.3.0/aderyn-report.md diff --git a/doc/audits/slither-report.md b/doc/audits/tools/v0.3.0/slither-report.md similarity index 100% rename from doc/audits/slither-report.md rename to doc/audits/tools/v0.3.0/slither-report.md diff --git a/specificationSnapshotEngineV0.3.0.pdf b/doc/specification/specificationSnapshotEngineV0.3.0.pdf similarity index 100% rename from specificationSnapshotEngineV0.3.0.pdf rename to doc/specification/specificationSnapshotEngineV0.3.0.pdf diff --git a/package-lock.json b/package-lock.json index 2501600..4e6cb37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,8 +7,8 @@ "name": "CMTAT Factory", "license": "MPL", "dependencies": { - "@openzeppelin/contracts": "5.4.0", - "@openzeppelin/contracts-upgradeable": "^5.4.0" + "@openzeppelin/contracts": "5.6.1", + "@openzeppelin/contracts-upgradeable": "5.6.1" }, "devDependencies": { "@nomicfoundation/hardhat-chai-matchers": "^2.0.7", @@ -4091,16 +4091,18 @@ } }, "node_modules/@openzeppelin/contracts": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.4.0.tgz", - "integrity": "sha512-eCYgWnLg6WO+X52I16TZt8uEjbtdkgLC0SUX/xnAksjjrQI4Xfn4iBRoI5j55dmlOhDv1Y7BoR3cU7e3WWhC6A==" + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-5.6.1.tgz", + "integrity": "sha512-Ly6SlsVJ3mj+b18W3R8gNufB7dTICT105fJhodGAGgyC2oqnBAhqSiNDJ8V8DLY05cCz81GLI0CU5vNYA1EC/w==", + "license": "MIT" }, "node_modules/@openzeppelin/contracts-upgradeable": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.4.0.tgz", - "integrity": "sha512-STJKyDzUcYuB35Zub1JpWW58JxvrFFVgQ+Ykdr8A9PGXgtq/obF5uoh07k2XmFyPxfnZdPdBdhkJ/n2YxJ87HQ==", + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-5.6.1.tgz", + "integrity": "sha512-n4a/vfRs114lXyUdYg7pyY8LvFKWvCDF5lEcRRAVxap8g6ZEdLqm+9tmt2zTtRHcNMxTYp9y5t6KBof4tHp7Og==", + "license": "MIT", "peerDependencies": { - "@openzeppelin/contracts": "5.4.0" + "@openzeppelin/contracts": "5.6.1" } }, "node_modules/@openzeppelin/defender-sdk-base-client": { diff --git a/package.json b/package.json index ba65bb4..196ce31 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "surya": "^0.4.11" }, "dependencies": { - "@openzeppelin/contracts": "5.4.0", - "@openzeppelin/contracts-upgradeable": "^5.4.0" + "@openzeppelin/contracts": "5.6.1", + "@openzeppelin/contracts-upgradeable": "5.6.1" } } diff --git a/test/CMTATSnapshot.test.js b/test/CMTATSnapshot.test.js index dfbe9a4..3fa4314 100644 --- a/test/CMTATSnapshot.test.js +++ b/test/CMTATSnapshot.test.js @@ -1,45 +1,21 @@ -const ERC20SnapshotModuleCommonRescheduling = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling') -const ERC20SnapshotModuleCommonScheduling = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling') -const ERC20SnapshotModuleCommonUnschedule = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule') -const ERC20SnapshotModuleCommonGetNextSnapshot = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonGetNextSnapshot') -const ERC20SnapshotModuleMultiplePlannedTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest') -const ERC20SnapshotModuleOnePlannedSnapshotTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleOnePlannedSnapshotTest') -const ERC20SnapshotModuleZeroPlannedSnapshotTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleZeroPlannedSnapshot') -const { ethers, upgrades } = require("hardhat"); -const { ZeroAddress, keccak256, toUtf8Bytes } = require("ethers"); const { + registerSnapshotCommonSuites +} = require('./ERC20SnapshotModuleCommon/registerSnapshotCommonSuites') +const { ethers, upgrades } = require('hardhat') +const { + deployCMTATProxyWithParameter, fixture, loadFixture } = require('../CMTAT/test/deploymentUtils') -const { zeroAddress } = require('ethereumjs-util') +const { buildCmtatInitParams } = require('./helpers/cmtatInitParams') describe('CMTAT Snapshot Upgradeable', function () { beforeEach(async function () { - const ruleEngine = ZeroAddress; - const snapshotEngine = ZeroAddress; - const documentEngine = ZeroAddress; - const ERC20Attributes = { - name: "Security Token", - symbol: "ST", - decimalsIrrevocable: 0 // Compliant with CMTAT spec but can be different - }; - - const terms = { - name: "Token Terms v1", - uri: "https://cmta.ch/standards/cmta-token-cmtat", - documentHash: keccak256(toUtf8Bytes("terms-v1")) - }; - - const extraInformationAttributes = { - tokenId: "1234567890", // ISIN or identifier - terms: terms, - information: "CMTAT smart contract" - }; - - const engines = { - ruleEngine: ruleEngine, - snapshotEngine: snapshotEngine, - documentEngine: documentEngine - }; + this.snapshotAdminMode = 'access-control' + const { + ERC20Attributes, + extraInformationAttributes, + engines + } = buildCmtatInitParams() Object.assign(this, await loadFixture(fixture)) const ETHERS_CMTAT_PROXY_FACTORY = await ethers.getContractFactory( @@ -58,14 +34,33 @@ describe('CMTAT Snapshot Upgradeable', function () { from: this.admin.address, unsafeAllow: ['missing-initializer'] } - ) - this.transferEngineMock = this.cmtat + ).catch(async (error) => { + if (!String(error).includes('code is too large')) { + throw error + } + + this.cmtat = await deployCMTATProxyWithParameter( + this.deployerAddress.address, + this._.address, + this.admin.address, + ERC20Attributes.name, + ERC20Attributes.symbol, + ERC20Attributes.decimalsIrrevocable, + extraInformationAttributes.tokenId, + extraInformationAttributes.terms, + extraInformationAttributes.information, + engines + ) + this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ + this.cmtat.target, this.admin + ]) + await this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) + return this.cmtat + }) + + if (!this.transferEngineMock) { + this.transferEngineMock = this.cmtat + } }) - ERC20SnapshotModuleMultiplePlannedTest() - ERC20SnapshotModuleOnePlannedSnapshotTest() - ERC20SnapshotModuleZeroPlannedSnapshotTest() - ERC20SnapshotModuleCommonRescheduling() - ERC20SnapshotModuleCommonScheduling() - ERC20SnapshotModuleCommonUnschedule() - ERC20SnapshotModuleCommonGetNextSnapshot() + registerSnapshotCommonSuites() }) diff --git a/test/CMTATStandaloneSnapshot.test.js b/test/CMTATStandaloneSnapshot.test.js new file mode 100644 index 0000000..23ca5af --- /dev/null +++ b/test/CMTATStandaloneSnapshot.test.js @@ -0,0 +1,57 @@ +const { ethers } = require('hardhat') +const { + registerSnapshotCommonSuites +} = require('./ERC20SnapshotModuleCommon/registerSnapshotCommonSuites') +const { + deployCMTATStandaloneWithParameter, + fixture, + loadFixture +} = require('../CMTAT/test/deploymentUtils') +const { buildCmtatInitParams } = require('./helpers/cmtatInitParams') + +describe('CMTAT Snapshot Standalone', function () { + beforeEach(async function () { + this.snapshotAdminMode = 'access-control' + const { + ERC20Attributes, + extraInformationAttributes, + engines + } = buildCmtatInitParams() + + Object.assign(this, await loadFixture(fixture)) + this.cmtat = await ethers.deployContract('CMTATStandaloneSnapshot', [ + this.admin.address, + ERC20Attributes, + extraInformationAttributes, + engines + ]).catch(async (error) => { + if (!String(error).includes('code is too large')) { + throw error + } + + this.cmtat = await deployCMTATStandaloneWithParameter( + this.deployerAddress.address, + this._.address, + this.admin.address, + ERC20Attributes.name, + ERC20Attributes.symbol, + ERC20Attributes.decimalsIrrevocable, + extraInformationAttributes.tokenId, + extraInformationAttributes.terms, + extraInformationAttributes.information, + engines + ) + this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ + this.cmtat.target, this.admin + ]) + await this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) + return this.cmtat + }) + + if (!this.transferEngineMock) { + this.transferEngineMock = this.cmtat + } + }) + + registerSnapshotCommonSuites() +}) diff --git a/test/ERC20SnapshotModule.test.js b/test/ERC20SnapshotModule.test.js index 6f86463..99ccaca 100644 --- a/test/ERC20SnapshotModule.test.js +++ b/test/ERC20SnapshotModule.test.js @@ -1,10 +1,6 @@ -const ERC20SnapshotModuleCommonRescheduling = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling') -const ERC20SnapshotModuleCommonScheduling = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling') -const ERC20SnapshotModuleCommonUnschedule = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule') -const ERC20SnapshotModuleCommonGetNextSnapshot = require('./ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonGetNextSnapshot') -const ERC20SnapshotModuleMultiplePlannedTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest') -const ERC20SnapshotModuleOnePlannedSnapshotTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleOnePlannedSnapshotTest') -const ERC20SnapshotModuleZeroPlannedSnapshotTest = require('./ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleZeroPlannedSnapshot') +const { + registerSnapshotCommonSuites +} = require('./ERC20SnapshotModuleCommon/registerSnapshotCommonSuites') const { deployCMTATStandalone, fixture, @@ -13,20 +9,16 @@ const { describe('Standard - ERC20SnapshotModule', function () { beforeEach(async function () { Object.assign(this, await loadFixture(fixture)) + this.snapshotAdminMode = 'access-control' this.cmtat = await deployCMTATStandalone( this._.address, this.admin.address, this.deployerAddress.address ) - this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ - this.cmtat.target, this.admin]) - this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) + this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ + this.cmtat.target, this.admin + ]) + await this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) }) - ERC20SnapshotModuleMultiplePlannedTest() - ERC20SnapshotModuleOnePlannedSnapshotTest() - ERC20SnapshotModuleZeroPlannedSnapshotTest() - ERC20SnapshotModuleCommonRescheduling() - ERC20SnapshotModuleCommonScheduling() - ERC20SnapshotModuleCommonUnschedule() - ERC20SnapshotModuleCommonGetNextSnapshot() + registerSnapshotCommonSuites() }) diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonExact.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonExact.js new file mode 100644 index 0000000..f8d50ca --- /dev/null +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonExact.js @@ -0,0 +1,55 @@ +const { time } = require('@nomicfoundation/hardhat-network-helpers') +const { expect } = require('chai') + +function ERC20SnapshotModuleCommonExact () { + context('Exact snapshot queries', function () { + beforeEach(async function () { + this.currentTime = await time.latest() + // Keep enough margin to avoid same-block "scheduled in the past" edge cases. + this.snapshotTime = this.currentTime + time.duration.seconds(30) + this.nonScheduledTime = this.currentTime + time.duration.seconds(31) + }) + + it('snapshotExists returns true only for exact scheduled times', async function () { + await this.transferEngineMock.connect(this.admin).scheduleSnapshot(this.snapshotTime) + + expect(await this.transferEngineMock.snapshotExists(this.snapshotTime)).to.equal(true) + expect(await this.transferEngineMock.snapshotExists(this.nonScheduledTime)).to.equal(false) + }) + + it('exact queries revert for non-scheduled timestamps', async function () { + await this.transferEngineMock.connect(this.admin).scheduleSnapshot(this.snapshotTime) + + await expect( + this.transferEngineMock.snapshotBalanceOfExact(this.nonScheduledTime, this.address1) + ).to.be.revertedWithCustomError( + this.transferEngineMock, + 'SnapshotEngine_SnapshotNotFound' + ) + + await expect( + this.transferEngineMock.snapshotTotalSupplyExact(this.nonScheduledTime) + ).to.be.revertedWithCustomError( + this.transferEngineMock, + 'SnapshotEngine_SnapshotNotFound' + ) + }) + + it('exact queries match legacy queries for scheduled timestamps', async function () { + await this.cmtat.connect(this.admin).mint(this.address1, 100) + await this.transferEngineMock.connect(this.admin).scheduleSnapshot(this.snapshotTime) + await time.increase(time.duration.seconds(10)) + await this.cmtat.connect(this.admin).mint(this.address1, 50) + + const legacyBalance = await this.transferEngineMock.snapshotBalanceOf(this.snapshotTime, this.address1) + const exactBalance = await this.transferEngineMock.snapshotBalanceOfExact(this.snapshotTime, this.address1) + expect(exactBalance).to.equal(legacyBalance) + + const legacyTotalSupply = await this.transferEngineMock.snapshotTotalSupply(this.snapshotTime) + const exactTotalSupply = await this.transferEngineMock.snapshotTotalSupplyExact(this.snapshotTime) + expect(exactTotalSupply).to.equal(legacyTotalSupply) + }) + }) +} + +module.exports = ERC20SnapshotModuleCommonExact diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonMaterialized.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonMaterialized.js new file mode 100644 index 0000000..ff207d3 --- /dev/null +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonMaterialized.js @@ -0,0 +1,48 @@ +const { time } = require('@nomicfoundation/hardhat-network-helpers') +const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs') +const { expect } = require('chai') +const { + expectUnauthorizedSnapshotAdmin +} = require('./ERC20SnapshotModuleUtils/authorization') + +function ERC20SnapshotModuleCommonMaterialized () { + context('Snapshot materialization', function () { + beforeEach(async function () { + this.currentTime = await time.latest() + this.snapshotTime = this.currentTime + time.duration.seconds(3) + }) + + it('emits SnapshotMaterialized when a due snapshot is materialized by token activity', async function () { + await this.transferEngineMock.connect(this.admin).scheduleSnapshot(this.snapshotTime) + await time.increase(time.duration.seconds(10)) + + const logs = await this.cmtat.connect(this.admin).mint(this.address1, 1) + await expect(logs) + .to.emit(this.transferEngineMock, 'SnapshotMaterialized') + .withArgs(this.snapshotTime, anyValue) + }) + + it('poke is access controlled', async function () { + await expectUnauthorizedSnapshotAdmin( + this, + this.transferEngineMock.connect(this.address1).poke(), + this.address1 + ) + }) + + it('poke materializes once and does not emit repeatedly for same snapshot', async function () { + await this.transferEngineMock.connect(this.admin).scheduleSnapshot(this.snapshotTime) + await time.increase(time.duration.seconds(10)) + + const firstPoke = await this.transferEngineMock.connect(this.admin).poke() + await expect(firstPoke) + .to.emit(this.transferEngineMock, 'SnapshotMaterialized') + .withArgs(this.snapshotTime, anyValue) + + const secondPoke = await this.transferEngineMock.connect(this.admin).poke() + await expect(secondPoke).to.not.emit(this.transferEngineMock, 'SnapshotMaterialized') + }) + }) +} + +module.exports = ERC20SnapshotModuleCommonMaterialized diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling.js index 768a487..026f688 100644 --- a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling.js +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonRescheduling.js @@ -1,6 +1,8 @@ const { time } = require('@nomicfoundation/hardhat-network-helpers') const { expect } = require('chai') -const { SNAPSHOOTER_ROLE } = require('../utils') +const { + expectUnauthorizedSnapshotAdmin +} = require('./ERC20SnapshotModuleUtils/authorization') const { checkArraySnapshot } = require('./ERC20SnapshotModuleUtils/ERC20SnapshotModuleUtils') @@ -132,16 +134,13 @@ function ERC20SnapshotModuleCommonRescheduling () { it('reverts when calling from non-owner', async function () { // Act - await expect( + await expectUnauthorizedSnapshotAdmin( + this, this.transferEngineMock .connect(this.address1) - .rescheduleSnapshot(this.snapshotTime, this.newSnapshotTime) + .rescheduleSnapshot(this.snapshotTime, this.newSnapshotTime), + this.address1 ) - .to.be.revertedWithCustomError( - this.transferEngineMock, - 'AccessControlUnauthorizedAccount' - ) - .withArgs(this.address1.address, SNAPSHOOTER_ROLE) }) it('reverts when trying to reschedule a snapshot in the past', async function () { diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling.js index e047f3e..08b5dee 100644 --- a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling.js +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonScheduling.js @@ -1,6 +1,8 @@ const { time } = require('@nomicfoundation/hardhat-network-helpers') const { expect } = require('chai') -const { SNAPSHOOTER_ROLE } = require('../utils') +const { + expectUnauthorizedSnapshotAdmin +} = require('./ERC20SnapshotModuleUtils/authorization') const { checkArraySnapshot } = require('./ERC20SnapshotModuleUtils/ERC20SnapshotModuleUtils') @@ -29,14 +31,11 @@ function ERC20SnapshotModuleCommonScheduling () { it('reverts when calling from non-admin', async function () { const SNAPSHOT_TIME = this.currentTime + time.duration.seconds(60) - await expect( - this.transferEngineMock.connect(this.address1).scheduleSnapshot(SNAPSHOT_TIME) + await expectUnauthorizedSnapshotAdmin( + this, + this.transferEngineMock.connect(this.address1).scheduleSnapshot(SNAPSHOT_TIME), + this.address1 ) - .to.be.revertedWithCustomError( - this.transferEngineMock, - 'AccessControlUnauthorizedAccount' - ) - .withArgs(this.address1.address, SNAPSHOOTER_ROLE) }) it('reverts when trying to schedule a snapshot before the last snapshot', async function () { @@ -165,16 +164,13 @@ function ERC20SnapshotModuleCommonScheduling () { it('reverts when calling from non-admin', async function () { const SNAPSHOT_TIME = this.currentTime + time.duration.seconds(60) - await expect( + await expectUnauthorizedSnapshotAdmin( + this, this.transferEngineMock .connect(this.address1) - .scheduleSnapshotNotOptimized(SNAPSHOT_TIME) + .scheduleSnapshotNotOptimized(SNAPSHOT_TIME), + this.address1 ) - .to.be.revertedWithCustomError( - this.transferEngineMock, - 'AccessControlUnauthorizedAccount' - ) - .withArgs(this.address1.address, SNAPSHOOTER_ROLE) }) it('reverts when trying to schedule a snapshot in the past', async function () { diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule.js index f968027..4e05f7f 100644 --- a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule.js +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleCommonUnschedule.js @@ -1,6 +1,8 @@ const { time } = require('@nomicfoundation/hardhat-network-helpers') const { expect } = require('chai') -const { SNAPSHOOTER_ROLE } = require('../utils') +const { + expectUnauthorizedSnapshotAdmin +} = require('./ERC20SnapshotModuleUtils/authorization') const { checkArraySnapshot } = require('./ERC20SnapshotModuleUtils/ERC20SnapshotModuleUtils') @@ -25,9 +27,12 @@ function ERC20SnapshotModuleCommonUnschedule () { let snapshots = await this.transferEngineMock.getNextSnapshots() expect(snapshots.length).to.equal(1) expect(snapshots[0]).to.equal(SNAPSHOT_TIME) - await this.transferEngineMock + this.logs = await this.transferEngineMock .connect(this.admin) .unscheduleSnapshotNotOptimized(SNAPSHOT_TIME) + await expect(this.logs) + .to.emit(this.transferEngineMock, 'SnapshotUnschedule') + .withArgs(SNAPSHOT_TIME) snapshots = await this.transferEngineMock.getNextSnapshots() expect(snapshots.length).to.equal(0) }) @@ -91,7 +96,7 @@ function ERC20SnapshotModuleCommonUnschedule () { checkArraySnapshot(snapshots, [ this.snapshotTime1, this.snapshotTime2, - this.RANDOM_SNAPSHOT, + RANDOM_SNAPSHOT, this.snapshotTime3, this.snapshotTime4, this.snapshotTime5 @@ -147,16 +152,13 @@ function ERC20SnapshotModuleCommonUnschedule () { expect(snapshots.length).to.equal(1) expect(snapshots[0]).to.equal(SNAPSHOT_TIME) // Act - await expect( + await expectUnauthorizedSnapshotAdmin( + this, this.transferEngineMock .connect(this.address1) - .unscheduleSnapshotNotOptimized(SNAPSHOT_TIME) + .unscheduleSnapshotNotOptimized(SNAPSHOT_TIME), + this.address1 ) - .to.be.revertedWithCustomError( - this.transferEngineMock, - 'AccessControlUnauthorizedAccount' - ) - .withArgs(this.address1.address, SNAPSHOOTER_ROLE) // Assert snapshots = await this.transferEngineMock.getNextSnapshots() expect(snapshots.length).to.equal(1) @@ -182,16 +184,13 @@ function ERC20SnapshotModuleCommonUnschedule () { }) it('reverts when calling from non-admin', async function () { - await expect( + await expectUnauthorizedSnapshotAdmin( + this, this.transferEngineMock .connect(this.address1) - .unscheduleLastSnapshot(this.snapshotTime) + .unscheduleLastSnapshot(this.snapshotTime), + this.address1 ) - .to.be.revertedWithCustomError( - this.transferEngineMock, - 'AccessControlUnauthorizedAccount' - ) - .withArgs(this.address1.address, SNAPSHOOTER_ROLE) }) it('reverts if no snapshot is scheduled', async function () { diff --git a/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleUtils/authorization.js b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleUtils/authorization.js new file mode 100644 index 0000000..1e802d7 --- /dev/null +++ b/test/ERC20SnapshotModuleCommon/ERC20SnapshotModuleUtils/authorization.js @@ -0,0 +1,27 @@ +const { expect } = require('chai') +const { SNAPSHOOTER_ROLE } = require('../../utils') + +async function expectUnauthorizedSnapshotAdmin (context, callPromise, caller) { + const mode = context.snapshotAdminMode || 'access-control' + + if (mode === 'ownable-2step') { + await expect(callPromise) + .to.be.revertedWithCustomError( + context.transferEngineMock, + 'OwnableUnauthorizedAccount' + ) + .withArgs(caller.address) + return + } + + await expect(callPromise) + .to.be.revertedWithCustomError( + context.transferEngineMock, + 'AccessControlUnauthorizedAccount' + ) + .withArgs(caller.address, SNAPSHOOTER_ROLE) +} + +module.exports = { + expectUnauthorizedSnapshotAdmin +} diff --git a/test/ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest.js b/test/ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest.js index 07b497e..c556340 100644 --- a/test/ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest.js +++ b/test/ERC20SnapshotModuleCommon/global/ERC20SnapshotModuleMultiplePlannedTest.js @@ -384,7 +384,7 @@ function ERC20SnapshotModuleMultiplePlannedTest () { [ADDRESS1_INITIAL_MINT, ADDRESS2_INITIAL_MINT, ADDRESS3_INITIAL_MINT] ) // Values at the time of the first snapshot - checkSnapshot.call( + await checkSnapshot.call( this, this.snapshotTime1, TOTAL_SUPPLY_INITIAL_MINT, diff --git a/test/ERC20SnapshotModuleCommon/registerSnapshotCommonSuites.js b/test/ERC20SnapshotModuleCommon/registerSnapshotCommonSuites.js new file mode 100644 index 0000000..b808bda --- /dev/null +++ b/test/ERC20SnapshotModuleCommon/registerSnapshotCommonSuites.js @@ -0,0 +1,25 @@ +const ERC20SnapshotModuleCommonRescheduling = require('./ERC20SnapshotModuleCommonRescheduling') +const ERC20SnapshotModuleCommonScheduling = require('./ERC20SnapshotModuleCommonScheduling') +const ERC20SnapshotModuleCommonUnschedule = require('./ERC20SnapshotModuleCommonUnschedule') +const ERC20SnapshotModuleCommonGetNextSnapshot = require('./ERC20SnapshotModuleCommonGetNextSnapshot') +const ERC20SnapshotModuleCommonExact = require('./ERC20SnapshotModuleCommonExact') +const ERC20SnapshotModuleCommonMaterialized = require('./ERC20SnapshotModuleCommonMaterialized') +const ERC20SnapshotModuleMultiplePlannedTest = require('./global/ERC20SnapshotModuleMultiplePlannedTest') +const ERC20SnapshotModuleOnePlannedSnapshotTest = require('./global/ERC20SnapshotModuleOnePlannedSnapshotTest') +const ERC20SnapshotModuleZeroPlannedSnapshotTest = require('./global/ERC20SnapshotModuleZeroPlannedSnapshot') + +function registerSnapshotCommonSuites () { + ERC20SnapshotModuleMultiplePlannedTest() + ERC20SnapshotModuleOnePlannedSnapshotTest() + ERC20SnapshotModuleZeroPlannedSnapshotTest() + ERC20SnapshotModuleCommonRescheduling() + ERC20SnapshotModuleCommonScheduling() + ERC20SnapshotModuleCommonUnschedule() + ERC20SnapshotModuleCommonGetNextSnapshot() + ERC20SnapshotModuleCommonExact() + ERC20SnapshotModuleCommonMaterialized() +} + +module.exports = { + registerSnapshotCommonSuites +} diff --git a/test/ERC20SnapshotModuleOwnable2Step.test.js b/test/ERC20SnapshotModuleOwnable2Step.test.js new file mode 100644 index 0000000..5ddeb28 --- /dev/null +++ b/test/ERC20SnapshotModuleOwnable2Step.test.js @@ -0,0 +1,26 @@ +const { + registerSnapshotCommonSuites +} = require('./ERC20SnapshotModuleCommon/registerSnapshotCommonSuites') +const { + deployCMTATStandalone, + fixture, + loadFixture +} = require('../CMTAT/test/deploymentUtils') + +describe('Standard - ERC20SnapshotModule Ownable2Step', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)) + this.snapshotAdminMode = 'ownable-2step' + this.cmtat = await deployCMTATStandalone( + this._.address, + this.admin.address, + this.deployerAddress.address + ) + this.transferEngineMock = await ethers.deployContract('SnapshotEngineOwnable2Step', [ + this.cmtat.target, this.admin + ]) + await this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) + }) + + registerSnapshotCommonSuites() +}) diff --git a/test/SnapshotEngineDeploy.test.js b/test/SnapshotEngineDeploy.test.js index b21f6ad..2153714 100644 --- a/test/SnapshotEngineDeploy.test.js +++ b/test/SnapshotEngineDeploy.test.js @@ -13,31 +13,34 @@ describe('Deploy Snapshot Engine', function () { this.admin.address, this.deployerAddress.address ) - this.transferEngineCustomError = await ethers.deployContract('SnapshotEngine', [ - this.cmtat.target, this.admin]) - this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineMock) + this.transferEngineCustomError = await ethers.deployContract( + 'SnapshotEngine', + [this.cmtat.target, this.admin] + ) + await this.cmtat.connect(this.admin).setSnapshotEngine(this.transferEngineCustomError) }) - context('Access Control', function () { it('testCannotTransferIfNotTokenBound', async function () { // Act this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ - this.cmtat.target, this.admin]) + this.cmtat.target, this.admin + ]) await expect( - this.transferEngineMock.operateOnTransfer(this.admin, this.admin, 1,1,1) - ).to.be.revertedWithCustomError( - this.transferEngineCustomError, - 'SnapshotEngine_UnauthorizedCaller' - ) + this.transferEngineMock.operateOnTransfer(this.admin, this.admin, 1, 1, 1) + ).to.be.revertedWithCustomError( + this.transferEngineCustomError, + 'SnapshotEngine_UnauthorizedCaller' + ) }) }) context('SnapshotEngineDeployment', function () { it('testHasTheRightVersion', async function () { this.transferEngineMock = await ethers.deployContract('SnapshotEngine', [ - this.cmtat.target, this.admin]) - expect(await this.transferEngineMock.version()).to.equal("0.3.0") + this.cmtat.target, this.admin + ]) + expect(await this.transferEngineMock.version()).to.equal('0.3.0') }) it('testCannotDeployIfERC20IsZero', async function () { diff --git a/test/helpers/cmtatInitParams.js b/test/helpers/cmtatInitParams.js new file mode 100644 index 0000000..980152b --- /dev/null +++ b/test/helpers/cmtatInitParams.js @@ -0,0 +1,41 @@ +const { ZeroAddress, keccak256, toUtf8Bytes } = require('ethers') + +function buildCmtatInitParams () { + const ruleEngine = ZeroAddress + const snapshotEngine = ZeroAddress + const documentEngine = ZeroAddress + + const ERC20Attributes = { + name: 'Security Token', + symbol: 'ST', + decimalsIrrevocable: 0 + } + + const terms = { + name: 'Token Terms v1', + uri: 'https://cmta.ch/standards/cmta-token-cmtat', + documentHash: keccak256(toUtf8Bytes('terms-v1')) + } + + const extraInformationAttributes = { + tokenId: '1234567890', + terms, + information: 'CMTAT smart contract' + } + + const engines = { + ruleEngine, + snapshotEngine, + documentEngine + } + + return { + ERC20Attributes, + extraInformationAttributes, + engines + } +} + +module.exports = { + buildCmtatInitParams +}