diff --git a/.claude/skills/cmtat/SKILL.md b/.claude/skills/cmtat/SKILL.md new file mode 100644 index 0000000..69254d1 --- /dev/null +++ b/.claude/skills/cmtat/SKILL.md @@ -0,0 +1,102 @@ +--- +name: cmta +description: main concept behind cmta +--- + +CMTAT (CMTA Token) is a **security token framework** for tokenizing real-world financial assets on EVM-compatible blockchains. It's developed by the Capital Markets and Technology Association (CMTA). + +**Version:** 3.2.0-rc0 | **License:** MPL-2.0 | **Solidity:** 0.8.33 + +--- + +## Directory Structure + +``` +contracts/ +├── modules/ # Core smart contract logic +│ ├── internal/ # Internal implementations +│ └── wrapper/ # Public-facing modules +│ ├── core/ # ERC20, Pause, Enforcement, Validation +│ ├── extensions/ # Documents, Snapshots +│ ├── options/ # Debt, ERC2771, Cross-chain +│ └── security/ # Access control +├── deployment/ # Pre-composed contract variants +├── interfaces/ # ERC standards & custom interfaces +└── mocks/ # Test helpers +test/ # 3,078 tests (~99% coverage) +doc/ # Specs & audit reports +``` + +--- + +## Key Modules + +| Category | Modules | Purpose | +|----------|---------|---------| +| **Core** | ERC20Base, Mint, Burn, Pause, Enforcement, Validation | Basic token operations | +| **Extensions** | Snapshot, Document, ExtraInformation | Dividends, legal docs, metadata | +| **Options** | Debt, ERC2771, ERC1363, CrossChain, Allowlist, ERC7551 | Bonds, gasless tx, multi-chain, KYC | +| **Security** | AccessControl | RBAC with roles (MINTER, BURNER, PAUSER, ENFORCER) | + +--- + +## Deployment Variants + +- **Standalone** - Immutable, no proxy +- **Upgradeable** - Transparent/Beacon/UUPS proxy patterns +- **Light** - Minimal for stablecoins +- **Allowlist** - Whitelist-based transfers (KYC) +- **Debt** - Bond-specific fields (maturity, coupon) +- **DebtEngine** - Debt with external engine +- **ERC-7551** - German eWpG compliance +- **ERC-1363** - transferAndCall support + +--- + +## Architecture Highlights + +1. **Modular composition** - Mix-and-match features via inheritance +2. **Engine pattern** - External contracts for complex logic (RuleEngine, SnapshotEngine, DocumentEngine, DebtEngine) +3. **ERC-7201 storage** - Namespaced storage for safe upgrades +4. **Role-based access control** - Granular permissions (not single owner) +5. **10+ standard compliance** - ERC-20, ERC-3643, ERC-7551, ERC-2771, ERC-7802, etc. + +--- + +## Contract Inheritance Hierarchy + +``` +CMTATBaseCore (0) - Basic ERC20 + Mint + Burn + Validation + Access Control + ↓ +CMTATBaseAccessControl (1) - RBAC roles management + ↓ +CMTATBaseRuleEngine/Allowlist (2) - Transfer validation rules + ↓ +CMTATBaseERC1404 (3) - ERC-1404 compliance (restrictedTransfer) + ↓ +CMTATBaseERC20CrossChain (4) - CCIP & ERC-7802 support + ↓ +CMTATBaseERC2771 (5) - Gasless meta-transactions + ↓ +CMTATBaseERC1363/ERC7551 (6) - Additional standards +``` + +--- + +## Key Roles (Access Control) + +- `DEFAULT_ADMIN_ROLE` - Admin access (can grant/revoke roles) +- `MINTER_ROLE` - Can mint tokens +- `BURNER_ROLE` - Can burn tokens +- `PAUSER_ROLE` - Can pause/unpause contract +- `ENFORCER_ROLE` - Can freeze/unfreeze addresses + +--- + +## Key Files to Understand + +- `contracts/modules/0_CMTATBaseCore.sol` - Core base contract +- `contracts/deployment/CMTAT_*.sol` - Pre-composed deployment variants +- `contracts/interfaces/` - All supported interfaces and standards +- `hardhat.config.js` - Build configuration +- `package.json` - Dependencies and scripts diff --git a/.claude/skills/erc173-ownership/SKILL.md b/.claude/skills/erc173-ownership/SKILL.md new file mode 100644 index 0000000..468792c --- /dev/null +++ b/.claude/skills/erc173-ownership/SKILL.md @@ -0,0 +1,90 @@ +--- +name: erc173-ownership +description: ERC-173-A standard interface for ownership of contracts +--- + +## Abstract + +This specification defines standard functions for owning or controlling a contract. + +An implementation allows reading the current owner (`owner() returns (address)`) and transferring ownership (`transferOwnership(address newOwner)`) along with a standardized event for when ownership is changed (`OwnershipTransferred(address indexed previousOwner, address indexed newOwner)`). + +## Motivation + +Many smart contracts require that they be owned or controlled in some way. For example to withdraw funds or perform administrative actions. It is so common that the contract interface used to handle contract ownership should be standardized to allow compatibility with user interfaces and contracts that manage contracts. + +Here are some examples of kinds of contracts and applications that can benefit from this standard: +1. Exchanges that buy/sell/auction ethereum contracts. This is only widely possible if there is a standard for getting the owner of a contract and transferring ownership. +2. Contract wallets that hold the ownership of contracts and that can transfer the ownership of contracts. +3. Contract registries. It makes sense for some registries to only allow the owners of contracts to add/remove their contracts. A standard must exist for these contract registries to verify that a contract is being submitted by the owner of it before accepting it. +4. User interfaces that show and transfer ownership of contracts. + +## Specification + +Every ERC-173 compliant contract must implement the `ERC173` interface. Contracts should also implement `ERC165` for the ERC-173 interface. + +```solidity + +/// @title ERC-173 Contract Ownership Standard +/// Note: the ERC-165 identifier for this interface is 0x7f5828d0 +interface ERC173 /* is ERC165 */ { + /// @dev This emits when ownership of a contract changes. + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /// @notice Get the address of the owner + /// @return The address of the owner. + function owner() view external returns(address); + + /// @notice Set the address of the new owner of the contract + /// @dev Set _newOwner to address(0) to renounce any ownership. + /// @param _newOwner The address of the new owner of the contract + function transferOwnership(address _newOwner) external; +} + +interface ERC165 { + /// @notice Query if a contract implements an interface + /// @param interfaceID The interface identifier, as specified in ERC-165 + /// @dev Interface identification is specified in ERC-165. + /// @return `true` if the contract implements `interfaceID` and + /// `interfaceID` is not 0xffffffff, `false` otherwise + function supportsInterface(bytes4 interfaceID) external view returns (bool); +} +``` + +The `owner()` function may be implemented as `pure` or `view`. + +The `transferOwnership(address _newOwner)` function may be implemented as `public` or `external`. + +To renounce any ownership of a contract set `_newOwner` to the zero address: `transferOwnership(address(0))`. If this is done then a contract is no longer owned by anybody. + +The OwnershipTransferred event should be emitted when a contract is created. + +## Rationale + +Key factors influencing the standard: +- Keeping the number of functions in the interface to a minimum to prevent contract bloat. +- Backwards compatibility with existing contracts. +- Simplicity +- Gas efficient + +Several ownership schemes were considered. The scheme chosen in this standard was chosen because of its simplicity, low gas cost and backwards compatibility with existing contracts. + +Here are other schemes that were considered: +1. **Associating an Ethereum Name Service (ENS) domain name with a contract.** A contract's `owner()` function could look up the owner address of a particular ENS name and use that as the owning address of the contract. Using this scheme a contract could be transferred by transferring the ownership of the ENS domain name to a different address. Short comings to this approach are that it is not backwards compatible with existing contracts and requires gas to make external calls to ENS related contracts to get the owner address. +2. **Associating an ERC721-based non-fungible token (NFT) with a contract.** Ownership of a contract could be tied to the ownership of an NFT. The benefit of this approach is that the existing ERC721-based infrastructure could be used to sell/buy/auction contracts. Short comings to this approach are additional complexity and infrastructure required. A contract could be associated with a particular NFT but the NFT would not track that it had ownership of a contract unless it was programmed to track contracts. In addition handling ownership of contracts this way is not backwards compatible. + +This standard does not exclude the above ownership schemes or other schemes from also being implemented in the same contract. For example a contract could implement this standard and also implement the other schemes so that ownership could be managed and transferred in multiple ways. This standard does provide a simple ownership scheme that is backwards compatible, is light-weight and simple to implement, and can be widely adopted and depended on. + +This standard can be (and has been) extended by other standards to add additional ownership functionality. + +## Security Considerations + +If the address returned by `owner()` is an externally owned account then its private key must not be lost or compromised. + +## Backwards Compatibility + +Many existing contracts already implement this standard. + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). diff --git a/.claude/skills/foundry/SKILL.md b/.claude/skills/foundry/SKILL.md new file mode 100644 index 0000000..d0c0ff6 --- /dev/null +++ b/.claude/skills/foundry/SKILL.md @@ -0,0 +1,293 @@ +--- +name: foundry +description: Instructions for Foundry Development (test & deployment script) +--- + +This file provides instructions for Claude Code when working with Foundry tests and deployment scripts in this project. + +## Project Context + +This is a Foundry-based Solidity project for CMTAT. The main contracts are located in `lib/CMTAT/contracts/`. + +## Writing Foundry Tests + +### File Structure + +- Place test files in `test/` directory +- Name test files with `.t.sol` suffix (e.g., `MyModule.t.sol`) +- Use `HelperContract.sol` as base for shared utilities + +### Test Contract Template + +```solidity +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "./HelperContract.sol"; + +contract MyModuleTest is HelperContract { + function setUp() public { + _deployToken(); + // Additional setup + } + + function test_DescriptiveName() public { + // Test implementation + } + + function test_RevertWhen_Condition() public { + vm.expectRevert(); + // Call that should revert + } +} +``` + +### Test Naming Conventions + +- `test_` prefix for standard tests +- `test_RevertWhen_` prefix for tests expecting reverts +- `testFuzz_` prefix for fuzz tests +- Use descriptive names: `test_AdminCanMint`, `test_RevertWhen_NonOwnerCalls` + +### Key Cheatcodes + +```solidity +// Change msg.sender for next call +vm.prank(address); + +// Change msg.sender for all subsequent calls +vm.startPrank(address); +vm.stopPrank(); + +// Expect a revert +vm.expectRevert(); +vm.expectRevert(CustomError.selector); +vm.expectRevert("Error message"); + +// Expect an event +vm.expectEmit(true, true, false, true); +emit ExpectedEvent(param1, param2, param3); +actualCall(); + +// Manipulate block properties +vm.warp(timestamp); // Set block.timestamp +vm.roll(blockNumber); // Set block.number + +// Deal ETH or tokens +deal(address, amount); +deal(tokenAddress, recipient, amount); + +// Environment variables +vm.envOr("VAR_NAME", defaultValue); +vm.envString("VAR_NAME"); +``` + +### Assertions + +```solidity +assertEq(actual, expected); +assertEq(actual, expected, "Error message"); +assertTrue(condition); +assertFalse(condition); +assertGt(a, b); // a > b +assertGe(a, b); // a >= b +assertLt(a, b); // a < b +assertLe(a, b); // a <= b +assertApproxEqAbs(a, b, maxDelta); +assertApproxEqRel(a, b, maxPercentDelta); +``` + +### Testing Events + +```solidity +// Declare event (copy from contract or interface) +event Transfer(address indexed from, address indexed to, uint256 value); + +function test_EmitsTransferEvent() public { + vm.expectEmit(true, true, false, true); + emit Transfer(from, to, amount); + token.transfer(to, amount); +} +``` + +### Testing Access Control + +```solidity +function test_RevertWhen_NonAdminCalls() public { + vm.prank(USER1); // USER1 is not admin + vm.expectRevert(); + cmtat.adminOnlyFunction(); +} + +function test_AdminCanCall() public { + vm.prank(ADMIN); + cmtat.adminOnlyFunction(); + // Assert expected state changes +} +``` + +### Available Test Addresses (from HelperContract) + +```solidity +address constant ZERO_ADDRESS = address(0); +address constant ADMIN = address(1); +address constant USER1 = address(2); +address constant USER2 = address(3); +address constant USER3 = address(4); +``` + +### Available Roles (from HelperContract) + +```solidity +bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; +bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); +bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE"); +bytes32 public constant ENFORCER_ROLE = keccak256("ENFORCER_ROLE"); +bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); +``` + +## Writing Deployment Scripts + +### File Structure + +- Place scripts in `script/` directory +- Name script files with `.s.sol` suffix +- Use descriptive names: `DeployCMTATStandalone.s.sol` + +### Deployment Script Template + +```solidity +//SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +// Import contracts to deploy + +contract DeployMyContract is Script { + function run() external returns (MyContract) { + // Read config from environment + address admin = vm.envOr("ADMIN_ADDRESS", msg.sender); + + vm.startBroadcast(); + + MyContract deployed = new MyContract(admin); + + vm.stopBroadcast(); + + return deployed; + } +} +``` + +### Environment Variables for Scripts + +```solidity +// With default fallback +address admin = vm.envOr("ADMIN_ADDRESS", msg.sender); +uint256 amount = vm.envOr("AMOUNT", uint256(1000)); +string memory name = vm.envOr("TOKEN_NAME", "Default"); + +// Required (reverts if not set) +address required = vm.envAddress("REQUIRED_ADDRESS"); +``` + +### Running Scripts + +```bash +# Dry run (simulation) +forge script script/Deploy.s.sol + +# With RPC (simulation on fork) +forge script script/Deploy.s.sol --rpc-url $RPC_URL + +# Broadcast to network +forge script script/Deploy.s.sol --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast + +# With verification +forge script script/Deploy.s.sol --rpc-url $RPC_URL --private-key $PRIVATE_KEY --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY +``` + +### Multi-Chain Deployment + +```solidity +function run() public { + vm.createSelectFork("mainnet"); + vm.startBroadcast(); + new MyContract(); + vm.stopBroadcast(); + + vm.createSelectFork("polygon"); + vm.startBroadcast(); + new MyContract(); + vm.stopBroadcast(); +} +``` + +## Running Tests + +```bash +# Run all tests +forge test + +# Run with verbosity +forge test -vvv + +# Run specific contract +forge test --match-contract BurnModuleTest + +# Run specific test +forge test --match-test test_AdminCanBurn + +# Run with gas report +forge test --gas-report + +# Run with coverage +forge coverage + +# Watch mode +forge test --watch +``` + +## Project-Specific Notes + +### CMTAT Constructor Parameters + +When deploying CMTATStandalone, these structs are required: + +```solidity +ICMTATConstructor.ERC20Attributes memory erc20Attributes = ICMTATConstructor.ERC20Attributes({ + name: "Token Name", + symbol: "SYM", + decimalsIrrevocable: 0 +}); + +IERC1643CMTAT.DocumentInfo memory termsDoc = IERC1643CMTAT.DocumentInfo({ + name: "Terms", + uri: "https://example.com/terms", + documentHash: bytes32(0) +}); + +ICMTATConstructor.ExtraInformationAttributes memory extraInfoAttributes = ICMTATConstructor.ExtraInformationAttributes({ + tokenId: "TOKEN_ID", + terms: termsDoc, + information: "Token info" +}); + +ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine({ + ruleEngine: IRuleEngine(address(0)) +}); +``` + +### Required Imports for CMTAT + +```solidity +import "CMTAT/deployment/CMTATStandalone.sol"; +import "CMTAT/interfaces/technical/ICMTATConstructor.sol"; +import "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol"; +``` + +## Code Style + +- Use descriptive test names +- Group related tests with comments: `// ============ Section Name ============` +- Do NOT use `view` modifier on test functions that use assertions diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad34e60..e020f82 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,18 +11,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2 - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c #v1.3.1 with: version: nightly - name: Run Forge install run: forge install + - name: Setup NodeJS 20.5.0 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0 + with: + node-version: 20.5.0 + - name: Run Forge build run: forge build --sizes - - name: Run Forge tests - run: forge test -vvv --gas-report \ No newline at end of file + - name: Run Forge tests + run: forge test -vvv \ No newline at end of file diff --git a/README.md b/README.md index 13e5627..0071544 100644 --- a/README.md +++ b/README.md @@ -4,52 +4,112 @@ This repository includes the DebtEngine contract for the [CMTAT](https://github.com/CMTA/CMTAT) token. -The *debtEngine* allows to define `debt` and `credit events` for several different contracts. +The DebtEngine allows defining `debt` and `credit events` for several different contracts. -A `debt` is defined with the following attributes +## Data Structures + +A `debt` is represented as a `DebtInformation` struct composed of two parts: + +```solidity +struct DebtInformation { + DebtIdentifier debtIdentifier; + DebtInstrument debtInstrument; +} +``` + +**DebtIdentifier** — information on the issuer and other persons involved: + +```solidity +struct DebtIdentifier { + string issuerName; + string issuerDescription; + string guarantor; + string debtHolder; +} +``` + +**DebtInstrument** — information on the instrument: ```solidity -struct DebtBase { - uint256 interestRate; - uint256 parValue; - string guarantor; - string bondHolder; - string maturityDate; - string interestScheduleFormat; - string interestPaymentDate; - string dayCountConvention; - string businessDayConvention; - string publicHolidaysCalendar; - string issuanceDate; - string couponFrequency; +struct DebtInstrument { + uint256 interestRate; + uint256 parValue; + uint256 minimumDenomination; + string issuanceDate; + string maturityDate; + string couponPaymentFrequency; + string interestScheduleFormat; + string interestPaymentDate; + string dayCountConvention; + string businessDayConvention; + string currency; + address currencyContract; } ``` -A `creditEvents`is defined with the following attributes +A `creditEvents` is defined with the following attributes: ```solidity struct CreditEvents { - bool flagDefault; - bool flagRedeemed; - string rating; + bool flagDefault; + bool flagRedeemed; + string rating; } ``` -## How to include it +## Functions -While it has been designed for the CMTAT, the debtEngine can be used with others contracts to define debt and credit events. +| Function | Visibility | Access | Description | +| --- | --- | --- | --- | +| `debt()` | External | Public | Returns debt for the caller's contract | +| `debt(address)` | Public | Public | Returns debt for a specific contract | +| `creditEvents()` | External | Public | Returns credit events for the caller's contract | +| `creditEvents(address)` | Public | Public | Returns credit events for a specific contract | +| `hasDebt(address)` | External | Public | Returns true if debt has been explicitly set | +| `hasCreditEvents(address)` | External | Public | Returns true if credit events have been explicitly set | +| `setDebt(address, DebtInformation)` | External | `DEBT_MANAGER_ROLE` | Set debt for a contract | +| `setCreditEvents(address, CreditEvents)` | External | `CREDIT_EVENTS_MANAGER_ROLE` | Set credit events for a contract | +| `setDebtBatch(address[], DebtInformation[])` | External | `DEBT_MANAGER_ROLE` | Set debt for multiple contracts | +| `setCreditEventsBatch(address[], CreditEvents[])` | External | `CREDIT_EVENTS_MANAGER_ROLE` | Set credit events for multiple contracts | -For that, the only thing to do is to import in your contract the interface `IDebtEngine` which declares the two functions `debt` and `creditEvents`. +## Events -This interface can be found in [CMTAT/contracts/interfaces/engine/IDebtEngine.sol](https://github.com/CMTA/CMTAT/blob/master/contracts/interfaces/engine/IDebtEngine.sol) +| Event | Emitted when | +| --- | --- | +| `DebtSet(address indexed smartContract)` | Debt is set or updated for a contract | +| `CreditEventsSet(address indexed smartContract)` | Credit events are set or updated for a contract | + +## Access Control + +The contract uses OpenZeppelin `AccessControl` with the following roles: + +| Role | Permissions | +| --- | --- | +| `DEFAULT_ADMIN_ROLE` | Implicitly holds all roles. Can grant/revoke roles. | +| `DEBT_MANAGER_ROLE` | Can call `setDebt` and `setDebtBatch` | +| `CREDIT_EVENTS_MANAGER_ROLE` | Can call `setCreditEvents` and `setCreditEventsBatch` | + +## Compatibility + +| Component | Compatible Versions | +| --- | --- | +| **DebtEngine v0.3.0** (unaudited) | CMTAT >= v3.0.0 | +| **DebtEngine v0.2.0** (unaudited) | CMTAT v2.5.0 (unaudited) | + +## How to include it + +While it has been designed for the CMTAT, the DebtEngine can be used with other contracts to define debt and credit events. + +Import the `IDebtEngine` interface which declares the `debt` and `creditEvents` functions: ```solidity -interface IDebtEngine is IDebtGlobal { - function debt() external view returns(IDebtGlobal.DebtBase memory); - function creditEvents() external view returns(IDebtGlobal.CreditEvents memory); +interface IDebtEngine is ICMTATDebt, ICMTATCreditEvents { + // Inherits debt() and creditEvents() from parent interfaces } ``` +This interface can be found in [CMTAT/contracts/interfaces/engine/IDebtEngine.sol](https://github.com/CMTA/CMTAT/blob/master/contracts/interfaces/engine/IDebtEngine.sol) + ## Schema ### Inheritance @@ -68,17 +128,19 @@ interface IDebtEngine is IDebtGlobal { | :------------: | :------------------: | :----------------------------------------------------: | :------------: | :-----------: | | └ | **Function Name** | **Visibility** | **Mutability** | **Modifiers** | | | | | | | -| **DebtEngine** | Implementation | IDebtEngine, AccessControl, DebtEngineInvariantStorage | | | -| └ | | Public ❗️ | 🛑 | NO❗️ | -| └ | debt | External ❗️ | | NO❗️ | -| └ | debt | Public ❗️ | | NO❗️ | -| └ | creditEvents | External ❗️ | | NO❗️ | -| └ | creditEvents | Public ❗️ | | NO❗️ | -| └ | setDebt | External ❗️ | 🛑 | onlyRole | -| └ | setCreditEvents | External ❗️ | 🛑 | onlyRole | -| └ | setCreditEventsBatch | External ❗️ | 🛑 | onlyRole | -| └ | setDebtsBatch | External ❗️ | 🛑 | onlyRole | -| └ | hasRole | Public ❗️ | | NO❗️ | +| **DebtEngine** | Implementation | IDebtEngine, AccessControl, DebtEngineInvariantStorage, ERC2771Context | | | +| └ | \ | Public | 🛑 | NO | +| └ | debt | External | | NO | +| └ | debt | Public | | NO | +| └ | creditEvents | External | | NO | +| └ | creditEvents | Public | | NO | +| └ | hasDebt | External | | NO | +| └ | hasCreditEvents | External | | NO | +| └ | setDebt | External | 🛑 | onlyRole | +| └ | setCreditEvents | External | 🛑 | onlyRole | +| └ | setCreditEventsBatch | External | 🛑 | onlyRole | +| └ | setDebtBatch | External | 🛑 | onlyRole | +| └ | hasRole | Public | | NO | ### Legend @@ -87,37 +149,33 @@ interface IDebtEngine is IDebtGlobal { | 🛑 | Function can modify state | | 💵 | Function is payable | - - ## Gasless support (ERC-2771) -The DebtEngine supports client-side gasless transactions using the [Gas Station Network](https://docs.opengsn.org/#the-problem) (GSN) pattern, the main open standard for transfering fee payment to another account than that of the transaction issuer. The contract uses the OpenZeppelin contract `ERC2771ContextUpgradeable`, which allows a contract to get the original client with `_msgSender()` instead of the fee payer given by `msg.sender` while allowing upgrades on the main contract (see *Deployment via a proxy* above). +The DebtEngine supports client-side gasless transactions using the [Gas Station Network](https://docs.opengsn.org/#the-problem) (GSN) pattern, the main open standard for transferring fee payment to another account than that of the transaction issuer. The contract uses the OpenZeppelin `ERC2771Context` contract, which allows a contract to get the original client with `_msgSender()` instead of the fee payer given by `msg.sender`. -At deployment, the parameter `forwarder` inside the constructor has to be set with the defined address of the forwarder. Please note that the forwarder can not be changed after deployment. +At deployment, the parameter `forwarder` inside the constructor has to be set with the address of the forwarder. Please note that the forwarder can not be changed after deployment. Please see the OpenGSN [documentation](https://docs.opengsn.org/contracts/#receiving-a-relayed-call) for more details on what is done to support GSN in the contract. - - ## Dependencies The toolchain includes the following components, where the versions are the latest ones that we tested: - Foundry -- Solidity 0.8.26 (via solc-js) -- OpenZeppelin Contracts (submodule) [v5.0.2](https://github.com/OpenZeppelin/openzeppelin-contracts/releases/tag/v5.0.2) +- Solidity 0.8.20+ +- OpenZeppelin Contracts (submodule) v5.x - Tests - - [CMTAT v2.5.0-rc0](https://github.com/CMTA/CMTAT/releases/tag/v2.5.0-rc0) - - OpenZeppelin Contracts Upgradeable(submodule) [v5.0.2](https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/releases/tag/v5.0.2) + - [CMTAT v3.2.0-rc2](https://github.com/CMTA/CMTAT) + - OpenZeppelin Contracts Upgradeable (submodule) v5.x -The CMTAT contracts and the OpenZeppelin library are included as a submodule of the present repository. +The CMTAT contracts and the OpenZeppelin library are included as submodules of the present repository. ## Tools -### Prettier +### Format ```bash -npx prettier --write --plugin=prettier-plugin-solidity 'src/**/*.sol' +forge fmt ``` ### Slither @@ -132,84 +190,30 @@ See [./doc/script](./doc/script) ### Foundry -Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust. - -Foundry consists of: - -- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). -- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- **Anvil**: Local Ethereum node, akin to Ganache, Hardhat Network. -- **Chisel**: Fast, utilitarian, and verbose solidity REPL. - -#### Documentation - -[https://book.getfoundry.sh/](https://book.getfoundry.sh/) - -#### Usage - -##### Coverage - -```bash -$ forge coverage --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage -``` - -##### Gas report - -```bash -$ forge test --gas-report -``` - -##### Build - -```shell -$ forge build -``` - -##### Test +#### Build ```shell -$ forge test +forge build ``` -##### Format +#### Test ```shell -$ forge fmt +forge test ``` -##### Gas Snapshots +#### Coverage -```shell -$ forge snapshot -``` - -##### Anvil - -```shell -$ anvil -``` - -##### Deploy - -```shell -$ forge script script/Counter.s.sol:CounterScript --rpc-url --private-key -``` - -##### Cast - -```shell -$ cast +```bash +forge coverage --report lcov && genhtml lcov.info --branch-coverage --output-dir coverage ``` -##### Help +#### Gas report -```shell -$ forge --help -$ anvil --help -$ cast --help +```bash +forge test --gas-report ``` ## Intellectual property -The code is copyright (c) Capital Market and Technology Association, 2018-2024, and is released under [Mozilla Public License 2.0](https://github.com/CMTA/CMTAT/blob/master/LICENSE.md). - +The code is copyright (c) Capital Market and Technology Association, 2018-2026, and is released under [Mozilla Public License 2.0](https://github.com/CMTA/CMTAT/blob/master/LICENSE.md). diff --git a/foundry.toml b/foundry.toml index 25b918f..1c01a1e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,10 @@ [profile.default] +solc = "0.8.33" src = "src" out = "out" libs = ["lib"] +optimizer = true +optimizer_runs = 200 +evm_version = 'prague' # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/lib/CMTAT b/lib/CMTAT index e8048d4..903c261 160000 --- a/lib/CMTAT +++ b/lib/CMTAT @@ -1 +1 @@ -Subproject commit e8048d43b0299afd83f150d3725ab299994b4271 +Subproject commit 903c2613b8c508864317504678ca18904258ecdb diff --git a/lib/forge-std b/lib/forge-std index 1714bee..c29afdd 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d +Subproject commit c29afdd40a82db50a3d3709d324416be50050e5e diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts index dbb6104..fcbae53 160000 --- a/lib/openzeppelin-contracts +++ b/lib/openzeppelin-contracts @@ -1 +1 @@ -Subproject commit dbb6104ce834628e473d2173bbc9d47f81a9eec3 +Subproject commit fcbae5394ae8ad52d8e580a3477db99814b9d565 diff --git a/lib/openzeppelin-contracts-upgradeable b/lib/openzeppelin-contracts-upgradeable index 723f8ca..aa677e9 160000 --- a/lib/openzeppelin-contracts-upgradeable +++ b/lib/openzeppelin-contracts-upgradeable @@ -1 +1 @@ -Subproject commit 723f8cab09cdae1aca9ec9cc1cfa040c2d4b06c1 +Subproject commit aa677e9d28ed78fc427ec47ba2baef2030c58e7c diff --git a/src/DebtEngine.sol b/src/DebtEngine.sol index ee61e58..3904e79 100644 --- a/src/DebtEngine.sol +++ b/src/DebtEngine.sol @@ -1,40 +1,43 @@ // SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; -import "OZ/access/AccessControl.sol"; -import "OZ/metatx/ERC2771Context.sol"; -import "CMTAT/interfaces/engine/IDebtEngine.sol"; -import "./DebtEngineInvariantStorage.sol"; -import {IDebtGlobal} from "CMTAT/interfaces/IDebtGlobal.sol"; - -contract DebtEngine is - IDebtEngine, - AccessControl, - DebtEngineInvariantStorage, - ERC2771Context -{ +import {ERC2771Context} from "OZ/metatx/ERC2771Context.sol"; +import {IDebtEngine} from "CMTAT/interfaces/engine/IDebtEngine.sol"; +import {DebtEngineInvariantStorage} from "./DebtEngineInvariantStorage.sol"; + +abstract contract DebtEngine is IDebtEngine, DebtEngineInvariantStorage, ERC2771Context { /** * @notice * Get the current version of the smart contract */ - string public constant VERSION = "0.2.0"; + string public constant VERSION = "0.3.0"; + + // Events + event DebtSet(address indexed smartContract); + event CreditEventsSet(address indexed smartContract); // Mapping of debts and credit events to specific smart contracts - mapping(address => DebtBase) private _debts; + mapping(address => DebtInformation) private _debts; mapping(address => CreditEvents) private _creditEvents; + // Track whether debt/credit events have been explicitly set + mapping(address => bool) private _debtSet; + mapping(address => bool) private _creditEventsSet; + + modifier onlyDebtManager() { + _authorizeSetDebt(); + _; + } + + modifier onlyCreditEventsManager() { + _authorizeSetCreditEvents(); + _; + } + /** - * Constructor to initialize the admin role + * Constructor to initialize ERC2771 forwarder */ - constructor( - address admin, - address forwarderIrrevocable - ) ERC2771Context(forwarderIrrevocable) { - if (admin == address(0)) { - revert AdminWithAddressZeroNotAllowed(); - } - _grantRole(DEFAULT_ADMIN_ROLE, admin); - } + constructor(address forwarderIrrevocable) ERC2771Context(forwarderIrrevocable) {} /*////////////////////////////////////////////////////////////// PUBLIC/EXTERNAL FUNCTIONS @@ -44,105 +47,119 @@ contract DebtEngine is /** * @notice Function to get the debt for the sender's smart contract */ - function debt() external view returns (DebtBase memory) { - return debt(msg.sender); + function debt() external view returns (DebtInformation memory) { + return debt(_msgSender()); } /** * @notice Function to get the debt for a specific smart contract */ - function debt( - address smartContract_ - ) public view returns (DebtBase memory) { - DebtBase memory d = _debts[smartContract_]; - return d; + function debt(address smartContract_) public view returns (DebtInformation memory) { + return _debts[smartContract_]; } /** * @notice Function to get the credit events for the sender's smart contract */ function creditEvents() external view returns (CreditEvents memory) { - return creditEvents(msg.sender); + return creditEvents(_msgSender()); } /** * @notice Function to get the credit events for a specific smart contract */ - function creditEvents( - address smartContract_ - ) public view returns (CreditEvents memory) { - CreditEvents memory ce = _creditEvents[smartContract_]; - return ce; + function creditEvents(address smartContract_) public view returns (CreditEvents memory) { + return _creditEvents[smartContract_]; + } + + /** + * @notice Returns true if debt has been set for the given smart contract + */ + function hasDebt(address smartContract_) external view returns (bool) { + return _debtSet[smartContract_]; + } + + /** + * @notice Returns true if credit events have been set for the given smart contract + */ + function hasCreditEvents(address smartContract_) external view returns (bool) { + return _creditEventsSet[smartContract_]; } /* ============ RESTRICTED-FACING FUNCTIONS ============ */ /** * @notice Function to set the debt for a given smart contract */ - function setDebt( - address smartContract_, - DebtBase calldata debt_ - ) external onlyRole(DEBT_MANAGER_ROLE) { + function setDebt(address smartContract_, DebtInformation calldata debt_) external onlyDebtManager { + if (smartContract_ == address(0)) { + revert SmartContractWithAddressZeroNotAllowed(); + } _debts[smartContract_] = debt_; + _debtSet[smartContract_] = true; + emit DebtSet(smartContract_); } - /* + /** * @notice Function to set the credit events for a given smart contract */ - function setCreditEvents( - address smartContract_, - CreditEvents calldata creditEvents_ - ) external onlyRole(CREDIT_EVENTS_MANAGER_ROLE) { + function setCreditEvents(address smartContract_, CreditEvents calldata creditEvents_) + external + onlyCreditEventsManager + { + if (smartContract_ == address(0)) { + revert SmartContractWithAddressZeroNotAllowed(); + } _creditEvents[smartContract_] = creditEvents_; + _creditEventsSet[smartContract_] = true; + emit CreditEventsSet(smartContract_); } - /* - * @notice Batch version of {setCreditEventsBatch} + /** + * @notice Batch version of {setCreditEvents} */ - function setCreditEventsBatch( - address[] calldata smartContracts, - CreditEvents[] calldata creditEventsList - ) external onlyRole(CREDIT_EVENTS_MANAGER_ROLE) { + function setCreditEventsBatch(address[] calldata smartContracts, CreditEvents[] calldata creditEventsList) + external + onlyCreditEventsManager + { if (smartContracts.length != creditEventsList.length) { revert InvalidInputLength(); } for (uint256 i = 0; i < smartContracts.length; i++) { + if (smartContracts[i] == address(0)) { + revert SmartContractWithAddressZeroNotAllowed(); + } _creditEvents[smartContracts[i]] = creditEventsList[i]; + _creditEventsSet[smartContracts[i]] = true; + emit CreditEventsSet(smartContracts[i]); } } - /* - * @notice Batch version of {setDebtBatch} + /** + * @notice Batch version of {setDebt} */ - function setDebtBatch( - address[] calldata smartContracts, - DebtBase[] calldata debts - ) external onlyRole(DEBT_MANAGER_ROLE) { + function setDebtBatch(address[] calldata smartContracts, DebtInformation[] calldata debts) + external + onlyDebtManager + { if (smartContracts.length != debts.length) { revert InvalidInputLength(); } for (uint256 i = 0; i < smartContracts.length; i++) { + if (smartContracts[i] == address(0)) { + revert SmartContractWithAddressZeroNotAllowed(); + } _debts[smartContracts[i]] = debts[i]; + _debtSet[smartContracts[i]] = true; + emit DebtSet(smartContracts[i]); } } - /* ============ ACCESS CONTROL ============ */ + /* ============ AUTHORIZATION HOOKS ============ */ - /* - * @dev Returns `true` if `account` has been granted `role`. - */ - function hasRole( - bytes32 role, - address account - ) public view virtual override returns (bool) { - // The Default Admin has all roles - if (AccessControl.hasRole(DEFAULT_ADMIN_ROLE, account)) { - return true; - } - return AccessControl.hasRole(role, account); - } + function _authorizeSetDebt() internal virtual; + function _authorizeSetCreditEvents() internal virtual; /*////////////////////////////////////////////////////////////// ERC2771 @@ -151,36 +168,21 @@ contract DebtEngine is /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgSender() - internal - view - override(ERC2771Context, Context) - returns (address sender) - { + function _msgSender() internal view virtual override returns (address sender) { return ERC2771Context._msgSender(); } /** * @dev This surcharge is not necessary if you do not use ERC2771 */ - function _msgData() - internal - view - override(ERC2771Context, Context) - returns (bytes calldata) - { + function _msgData() internal view virtual override returns (bytes calldata) { return ERC2771Context._msgData(); } /** * @dev This surcharge is not necessary if you do not use the MetaTxModule */ - function _contextSuffixLength() - internal - view - override(ERC2771Context, Context) - returns (uint256) - { + function _contextSuffixLength() internal view virtual override returns (uint256) { return ERC2771Context._contextSuffixLength(); } } diff --git a/src/DebtEngineInvariantStorage.sol b/src/DebtEngineInvariantStorage.sol index 36d46d5..4cf2c66 100644 --- a/src/DebtEngineInvariantStorage.sol +++ b/src/DebtEngineInvariantStorage.sol @@ -1,15 +1,8 @@ // SPDX-License-Identifier: MPL-2.0 pragma solidity ^0.8.20; -contract DebtEngineInvariantStorage { - // CreditEvents - bytes32 public constant CREDIT_EVENTS_MANAGER_ROLE = - keccak256("CREDIT_EVENTS_MANAGER_ROLE"); - - // DebtModule - bytes32 public constant DEBT_MANAGER_ROLE = keccak256("DEBT_MANAGER_ROLE"); - - // custom error +abstract contract DebtEngineInvariantStorage { error AdminWithAddressZeroNotAllowed(); error InvalidInputLength(); + error SmartContractWithAddressZeroNotAllowed(); } diff --git a/src/access/DebtEngineAccessControl.sol b/src/access/DebtEngineAccessControl.sol new file mode 100644 index 0000000..1df7f27 --- /dev/null +++ b/src/access/DebtEngineAccessControl.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {AccessControl} from "OZ/access/AccessControl.sol"; +import {Context} from "OZ/utils/Context.sol"; +import {DebtEngine} from "../DebtEngine.sol"; + +contract DebtEngineAccessControl is DebtEngine, AccessControl { + bytes32 public constant CREDIT_EVENTS_MANAGER_ROLE = keccak256("CREDIT_EVENTS_MANAGER_ROLE"); + bytes32 public constant DEBT_MANAGER_ROLE = keccak256("DEBT_MANAGER_ROLE"); + + constructor(address admin, address forwarderIrrevocable) DebtEngine(forwarderIrrevocable) { + if (admin == address(0)) { + revert AdminWithAddressZeroNotAllowed(); + } + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + /* ============ AUTHORIZATION HOOKS ============ */ + + function _authorizeSetDebt() internal override view { + _checkRole(DEBT_MANAGER_ROLE); + } + + function _authorizeSetCreditEvents() internal override view { + _checkRole(CREDIT_EVENTS_MANAGER_ROLE); + } + + /* ============ ACCESS CONTROL ============ */ + + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole(bytes32 role, address account) public view virtual override returns (bool) { + // The Default Admin has all roles + if (AccessControl.hasRole(DEFAULT_ADMIN_ROLE, account)) { + return true; + } + return AccessControl.hasRole(role, account); + } + + /*////////////////////////////////////////////////////////////// + ERC2771 + //////////////////////////////////////////////////////////////*/ + + function _msgSender() internal view override(DebtEngine, Context) returns (address sender) { + return DebtEngine._msgSender(); + } + + function _msgData() internal view override(DebtEngine, Context) returns (bytes calldata) { + return DebtEngine._msgData(); + } + + function _contextSuffixLength() internal view override(DebtEngine, Context) returns (uint256) { + return DebtEngine._contextSuffixLength(); + } +} diff --git a/src/access/DebtEngineOwnable.sol b/src/access/DebtEngineOwnable.sol new file mode 100644 index 0000000..fb07f64 --- /dev/null +++ b/src/access/DebtEngineOwnable.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Ownable} from "OZ/access/Ownable.sol"; +import {Context} from "OZ/utils/Context.sol"; +import {DebtEngine} from "../DebtEngine.sol"; + +contract DebtEngineOwnable is DebtEngine, Ownable { + constructor(address owner, address forwarderIrrevocable) DebtEngine(forwarderIrrevocable) Ownable(owner) {} + + /* ============ AUTHORIZATION HOOKS ============ */ + + function _authorizeSetDebt() internal override view { + _checkOwner(); + } + + function _authorizeSetCreditEvents() internal override view { + _checkOwner(); + } + + /*////////////////////////////////////////////////////////////// + ERC2771 + //////////////////////////////////////////////////////////////*/ + + function _msgSender() internal view override(DebtEngine, Context) returns (address sender) { + return DebtEngine._msgSender(); + } + + function _msgData() internal view override(DebtEngine, Context) returns (bytes calldata) { + return DebtEngine._msgData(); + } + + function _contextSuffixLength() internal view override(DebtEngine, Context) returns (uint256) { + return DebtEngine._contextSuffixLength(); + } +} diff --git a/test/DebtEngine.t.sol b/test/DebtEngine.t.sol deleted file mode 100644 index 01b5cf7..0000000 --- a/test/DebtEngine.t.sol +++ /dev/null @@ -1,348 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -pragma solidity ^0.8.20; - -import "forge-std/Test.sol"; -import "../src/DebtEngine.sol"; -import "CMTAT/interfaces/engine/IDebtEngine.sol"; -import {IDebtGlobal} from "CMTAT/interfaces/IDebtGlobal.sol"; -import "../src/DebtEngineInvariantStorage.sol"; -import "OZ/access/AccessControl.sol"; -import "CMTAT/CMTAT_STANDALONE.sol"; -contract DebtEngineTest is - Test, - AccessControl, - IDebtGlobal, - DebtEngineInvariantStorage -{ - DebtEngine private debtEngine; - address private admin = address(0x1); - address private attacker = address(0x2); - address private testContract = address(0x3); - address private testContract1 = address(0x4); - address private testContract2 = address(0x5); - address AddressZero = address(0); - CMTAT_STANDALONE cmtat; - - // Sample data for DebtBase and CreditEvents - DebtEngine.DebtBase private debtSample = - DebtBase({ - interestRate: 5, - parValue: 1000, - guarantor: "Guarantor A", - bondHolder: "Bond Holder B", - maturityDate: "2025-01-01", - interestScheduleFormat: "Annual", - interestPaymentDate: "2024-12-31", - dayCountConvention: "30/360", - businessDayConvention: "Modified Following", - publicHolidaysCalendar: "US", - issuanceDate: "2023-01-01", - couponFrequency: "Semi-Annual" - }); - - DebtEngine.CreditEvents private creditEventSample = - CreditEvents({flagDefault: false, flagRedeemed: true, rating: "AAA"}); - - // Sample data for DebtBase and CreditEvents - DebtEngine.DebtBase private debtSample1 = - DebtBase({ - interestRate: 5, - parValue: 1000, - guarantor: "Guarantor A", - bondHolder: "Bond Holder B", - maturityDate: "2025-01-01", - interestScheduleFormat: "Annual", - interestPaymentDate: "2024-12-31", - dayCountConvention: "30/360", - businessDayConvention: "Modified Following", - publicHolidaysCalendar: "US", - issuanceDate: "2023-01-01", - couponFrequency: "Semi-Annual" - }); - - DebtEngine.DebtBase private debtSample2 = - DebtBase({ - interestRate: 6, - parValue: 2000, - guarantor: "Guarantor B", - bondHolder: "Bond Holder C", - maturityDate: "2026-01-01", - interestScheduleFormat: "Monthly", - interestPaymentDate: "2025-12-31", - dayCountConvention: "Actual/Actual", - businessDayConvention: "Following", - publicHolidaysCalendar: "UK", - issuanceDate: "2024-01-01", - couponFrequency: "Quarterly" - }); - - DebtEngine.CreditEvents private creditEventSample1 = - CreditEvents({flagDefault: false, flagRedeemed: true, rating: "AAA"}); - - DebtEngine.CreditEvents private creditEventSample2 = - CreditEvents({flagDefault: true, flagRedeemed: false, rating: "BBB"}); - - function setUp() public { - // Deploy the DebtEngine contract with admin role - debtEngine = new DebtEngine(admin, AddressZero); - ICMTATConstructor.ERC20Attributes - memory erc20Attributes = ICMTATConstructor.ERC20Attributes( - "CMTA Token", - "CMTAT", - 0 - ); - ICMTATConstructor.BaseModuleAttributes - memory baseModuleAttributes = ICMTATConstructor - .BaseModuleAttributes( - "CMTAT_ISIN", - "https://cmta.ch", - "CMTAT_info" - ); - ICMTATConstructor.Engine memory engines = ICMTATConstructor.Engine( - IRuleEngine(AddressZero), - IDebtEngine(AddressZero), - IAuthorizationEngine(AddressZero), - IERC1643(AddressZero) - ); - cmtat = new CMTAT_STANDALONE( - AddressZero, - admin, - erc20Attributes, - baseModuleAttributes, - engines - ); - } - - /*////////////////////////////////////////////////////////////// - DEPLOYMENT - ///////////////////////////////////////*/ - - function testDeploy() public { - address forwarder = address(0x1); - debtEngine = new DebtEngine(admin, forwarder); - - // Forwarder - assertEq(debtEngine.isTrustedForwarder(forwarder), true); - // admin - vm.expectRevert( - abi.encodeWithSelector(AdminWithAddressZeroNotAllowed.selector) - ); - debtEngine = new DebtEngine(AddressZero, forwarder); - } - - /*////////////////////////////////////////////////////////////// - Access control - ///////////////////////////////////////*/ - - function testSetDebtAsAdmin() public { - // Act - // Call as admin - vm.prank(admin); - debtEngine.setDebt(testContract, debtSample); - - // Assert - // Verify that debt was set correctly - DebtEngine.DebtBase memory debt = debtEngine.debt(testContract); - assertEq(debt.interestRate, 5); - assertEq(debt.parValue, 1000); - } - - function testCannotNonAdminSetDebt() public { - // Attempt to set debt as non-admin - vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DEBT_MANAGER_ROLE - ) - ); - debtEngine.setDebt(testContract, debtSample); - } - - function testCanAdminSetCreditEvents() public { - // Call as admin - vm.prank(admin); - debtEngine.setCreditEvents(testContract, creditEventSample); - - // Act - // Verify that credit events were set correctly - DebtEngine.CreditEvents memory credit = debtEngine.creditEvents( - testContract - ); - assertEq(credit.flagDefault, false); - assertEq(credit.flagRedeemed, true); - assertEq(credit.rating, "AAA"); - } - - function testCannotNonAdminSetCreditEvents() public { - // Act - // Attempt to set credit events as non-admin - vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - CREDIT_EVENTS_MANAGER_ROLE - ) - ); - debtEngine.setCreditEvents(testContract, creditEventSample); - } - - function testSetDebtsBatchAsAdmin() public { - // Call as admin to set multiple debts - address[] memory contracts = new address[](2); - contracts[0] = testContract1; - contracts[1] = testContract2; - - DebtEngine.DebtBase[] memory debts = new DebtEngine.DebtBase[](2); - debts[0] = debtSample1; - debts[1] = debtSample2; - - vm.prank(admin); - debtEngine.setDebtBatch(contracts, debts); - - // Verify that both debts were set correctly - DebtEngine.DebtBase memory debt1 = debtEngine.debt(testContract1); - assertEq(debt1.interestRate, 5); - assertEq(debt1.parValue, 1000); - - DebtEngine.DebtBase memory debt2 = debtEngine.debt(testContract2); - assertEq(debt2.interestRate, 6); - assertEq(debt2.parValue, 2000); - } - - function testSetDebtsBatchAsNonAdminFails() public { - // Attempt to set multiple debts as non-admin - address[] memory contracts = new address[](2); - contracts[0] = testContract1; - contracts[1] = testContract2; - - DebtEngine.DebtBase[] memory debts = new DebtEngine.DebtBase[](2); - debts[0] = debtSample1; - debts[1] = debtSample2; - - vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - DEBT_MANAGER_ROLE - ) - ); - debtEngine.setDebtBatch(contracts, debts); - } - - function testSetCreditEventsBatchAsAdmin() public { - // Call as admin to set multiple credit events - address[] memory contracts = new address[](2); - contracts[0] = testContract1; - contracts[1] = testContract2; - - DebtEngine.CreditEvents[] - memory creditEventsList = new DebtEngine.CreditEvents[](2); - creditEventsList[0] = creditEventSample1; - creditEventsList[1] = creditEventSample2; - - vm.prank(admin); - debtEngine.setCreditEventsBatch(contracts, creditEventsList); - - // Verify that both credit events were set correctly - DebtEngine.CreditEvents memory credit1 = debtEngine.creditEvents( - testContract1 - ); - assertEq(credit1.flagDefault, false); - assertEq(credit1.rating, "AAA"); - - DebtEngine.CreditEvents memory credit2 = debtEngine.creditEvents( - testContract2 - ); - assertEq(credit2.flagDefault, true); - assertEq(credit2.rating, "BBB"); - } - - function testSetCreditEventsBatchAsNonAdminFails() public { - // Attempt to set multiple credit events as non-admin - address[] memory contracts = new address[](2); - contracts[0] = testContract1; - contracts[1] = testContract2; - - DebtEngine.CreditEvents[] - memory creditEventsList = new DebtEngine.CreditEvents[](2); - creditEventsList[0] = creditEventSample1; - creditEventsList[1] = creditEventSample2; - - vm.prank(attacker); - vm.expectRevert( - abi.encodeWithSelector( - AccessControlUnauthorizedAccount.selector, - attacker, - CREDIT_EVENTS_MANAGER_ROLE - ) - ); - debtEngine.setCreditEventsBatch(contracts, creditEventsList); - } - - /*////////////////////////////////////////////////////////////// - Get - ///////////////////////////////////////*/ - - function testCanReturnCMTATDebt() public { - // Arrange - vm.prank(admin); - debtEngine.setDebt(address(cmtat), debtSample); - - vm.prank(admin); - cmtat.setDebtEngine(debtEngine); - - // Call from CMTAT, return debt smart contract - DebtEngine.DebtBase memory debt = cmtat.debt(); - assertEq(debt.parValue, 1000); - } - - function testCanReturnCMTATCreditEvents() public { - // Call as admin to set credit events for non-admin's contract - vm.prank(admin); - debtEngine.setCreditEvents(address(cmtat), creditEventSample); - - vm.prank(admin); - cmtat.setDebtEngine(debtEngine); - - // Call from attacker, should return credit events for attacker address - vm.prank(attacker); - DebtEngine.CreditEvents memory credit = cmtat.creditEvents(); - assertEq(credit.flagRedeemed, true); - } - - /*////////////////////////////////////////////////////////////// - INVALID PARAMETER - ///////////////////////////////////////*/ - function testSetDebtsBatchLengthMismatch() public { - // Set arrays with mismatched lengths - address[] memory contracts = new address[](1); - contracts[0] = testContract1; - - DebtEngine.DebtBase[] memory debts = new DebtEngine.DebtBase[](2); - debts[0] = debtSample1; - debts[1] = debtSample2; - - vm.prank(admin); - vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); - debtEngine.setDebtBatch(contracts, debts); - } - - function testSetCreditEventsBatchLengthMismatch() public { - // Set arrays with mismatched lengths - address[] memory contracts = new address[](1); - contracts[0] = testContract1; - - DebtEngine.CreditEvents[] - memory creditEventsList = new DebtEngine.CreditEvents[](2); - creditEventsList[0] = creditEventSample1; - creditEventsList[1] = creditEventSample2; - - vm.prank(admin); - vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); - debtEngine.setCreditEventsBatch(contracts, creditEventsList); - } -} diff --git a/test/DebtEngineAccessControl.t.sol b/test/DebtEngineAccessControl.t.sol new file mode 100644 index 0000000..a33688c --- /dev/null +++ b/test/DebtEngineAccessControl.t.sol @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {DebtEngineBaseTest} from "./DebtEngineBase.t.sol"; +import {DebtEngineAccessControl} from "../src/access/DebtEngineAccessControl.sol"; +import {IAccessControl} from "OZ/access/IAccessControl.sol"; +import {ICMTATDebt, ICMTATCreditEvents} from "CMTAT/interfaces/tokenization/ICMTAT.sol"; + +contract DebtEngineAccessControlTest is DebtEngineBaseTest { + DebtEngineAccessControl private debtEngineAC; + + bytes32 private DEBT_MANAGER_ROLE; + bytes32 private CREDIT_EVENTS_MANAGER_ROLE; + + function _deployEngine() internal override { + debtEngineAC = new DebtEngineAccessControl(admin, AddressZero); + debtEngine = debtEngineAC; + DEBT_MANAGER_ROLE = debtEngineAC.DEBT_MANAGER_ROLE(); + CREDIT_EVENTS_MANAGER_ROLE = debtEngineAC.CREDIT_EVENTS_MANAGER_ROLE(); + } + + function _expectUnauthorizedDebtRevert(address caller) internal override { + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, caller, DEBT_MANAGER_ROLE) + ); + } + + function _expectUnauthorizedCreditEventsRevert(address caller) internal override { + vm.expectRevert( + abi.encodeWithSelector( + IAccessControl.AccessControlUnauthorizedAccount.selector, caller, CREDIT_EVENTS_MANAGER_ROLE + ) + ); + } + + /*////////////////////////////////////////////////////////////// + DEPLOYMENT + ///////////////////////////////////////*/ + + function testDeploy() public override { + address forwarder = address(0x1); + debtEngineAC = new DebtEngineAccessControl(admin, forwarder); + + // Forwarder + assertEq(debtEngineAC.isTrustedForwarder(forwarder), true); + // admin + vm.expectRevert(abi.encodeWithSelector(AdminWithAddressZeroNotAllowed.selector)); + new DebtEngineAccessControl(AddressZero, forwarder); + } + + /*////////////////////////////////////////////////////////////// + Access control — unauthorized + ///////////////////////////////////////*/ + + function testCannotNonAdminSetDebt() public { + _expectUnauthorizedDebtRevert(attacker); + vm.prank(attacker); + debtEngine.setDebt(testContract, debtSample); + } + + function testCannotNonAdminSetCreditEvents() public { + _expectUnauthorizedCreditEventsRevert(attacker); + vm.prank(attacker); + debtEngine.setCreditEvents(testContract, creditEventSample); + } + + function testSetDebtsBatchAsNonAdminFails() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + _expectUnauthorizedDebtRevert(attacker); + vm.prank(attacker); + debtEngine.setDebtBatch(contracts, debts); + } + + function testSetCreditEventsBatchAsNonAdminFails() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + _expectUnauthorizedCreditEventsRevert(attacker); + vm.prank(attacker); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + } + + /*////////////////////////////////////////////////////////////// + RBAC-specific: role grant/revoke + ///////////////////////////////////////*/ + + function testGrantDebtManagerRole() public { + bytes32 role = DEBT_MANAGER_ROLE; + + vm.prank(admin); + debtEngineAC.grantRole(role, attacker); + + vm.prank(attacker); + debtEngine.setDebt(testContract, debtSample); + + ICMTATDebt.DebtInformation memory d = debtEngine.debt(testContract); + assertEq(d.debtInstrument.interestRate, 5); + } + + function testRevokeDebtManagerRole() public { + bytes32 role = DEBT_MANAGER_ROLE; + + vm.prank(admin); + debtEngineAC.grantRole(role, attacker); + + vm.prank(admin); + debtEngineAC.revokeRole(role, attacker); + + _expectUnauthorizedDebtRevert(attacker); + vm.prank(attacker); + debtEngine.setDebt(testContract, debtSample); + } + + function testGrantCreditEventsManagerRole() public { + bytes32 role = CREDIT_EVENTS_MANAGER_ROLE; + + vm.prank(admin); + debtEngineAC.grantRole(role, attacker); + + vm.prank(attacker); + debtEngine.setCreditEvents(testContract, creditEventSample); + + ICMTATCreditEvents.CreditEvents memory credit = debtEngine.creditEvents(testContract); + assertEq(credit.flagRedeemed, true); + } + + function testRevokeCreditEventsManagerRole() public { + bytes32 role = CREDIT_EVENTS_MANAGER_ROLE; + + vm.prank(admin); + debtEngineAC.grantRole(role, attacker); + + vm.prank(admin); + debtEngineAC.revokeRole(role, attacker); + + _expectUnauthorizedCreditEventsRevert(attacker); + vm.prank(attacker); + debtEngine.setCreditEvents(testContract, creditEventSample); + } +} diff --git a/test/DebtEngineBase.t.sol b/test/DebtEngineBase.t.sol new file mode 100644 index 0000000..b720c40 --- /dev/null +++ b/test/DebtEngineBase.t.sol @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {DebtEngine} from "../src/DebtEngine.sol"; +import {IDebtEngine} from "CMTAT/interfaces/engine/IDebtEngine.sol"; +import {ICMTATDebt, ICMTATCreditEvents} from "CMTAT/interfaces/tokenization/ICMTAT.sol"; +import {IERC1643CMTAT} from "CMTAT/interfaces/tokenization/draft-IERC1643CMTAT.sol"; +import {ISnapshotEngine} from "CMTAT/interfaces/engine/ISnapshotEngine.sol"; +import {IRuleEngine} from "CMTAT/interfaces/engine/IRuleEngine.sol"; +import {IERC1643} from "CMTAT/interfaces/tokenization/draft-IERC1643.sol"; +import {DebtEngineInvariantStorage} from "../src/DebtEngineInvariantStorage.sol"; +import {CMTATStandaloneDebt} from "CMTAT/deployment/debt/CMTATStandaloneDebt.sol"; +import {ICMTATConstructor} from "CMTAT/interfaces/technical/ICMTATConstructor.sol"; + +abstract contract DebtEngineBaseTest is Test, DebtEngineInvariantStorage { + DebtEngine internal debtEngine; + address internal admin = address(0x1); + address internal attacker = address(0x2); + address internal testContract = address(0x3); + address internal testContract1 = address(0x4); + address internal testContract2 = address(0x5); + address internal AddressZero = address(0); + CMTATStandaloneDebt internal cmtat; + + // Sample data for DebtInformation and CreditEvents + ICMTATDebt.DebtInformation internal debtSample; + + ICMTATCreditEvents.CreditEvents internal creditEventSample = + ICMTATCreditEvents.CreditEvents({flagDefault: false, flagRedeemed: true, rating: "AAA"}); + + ICMTATDebt.DebtIdentifier internal debtIdentifier1 = ICMTATDebt.DebtIdentifier({ + issuerName: "Name", issuerDescription: "Description", guarantor: "Guarantor A", debtHolder: "Bond Holder B" + }); + + ICMTATDebt.DebtInstrument internal debtSample1 = ICMTATDebt.DebtInstrument({ + interestRate: 5, + parValue: 1000, + minimumDenomination: 5000, + issuanceDate: "2023-01-01", + maturityDate: "2025-01-01", + couponPaymentFrequency: "Semi-Annual", + interestScheduleFormat: "Annual", + interestPaymentDate: "2024-12-31", + dayCountConvention: "30/360", + businessDayConvention: "Modified Following", + currency: "USDC", + currencyContract: address(0x3) + }); + + ICMTATDebt.DebtInstrument internal debtSample2 = ICMTATDebt.DebtInstrument({ + interestRate: 6, + parValue: 2000, + minimumDenomination: 0, + issuanceDate: "2024-01-01", + maturityDate: "2026-01-01", + couponPaymentFrequency: "Quarterly", + interestScheduleFormat: "Monthly", + interestPaymentDate: "2025-12-31", + dayCountConvention: "Actual/Actual", + businessDayConvention: "Following", + currency: "", + currencyContract: address(0) + }); + + ICMTATDebt.DebtIdentifier internal debtIdentifier2 = ICMTATDebt.DebtIdentifier({ + issuerName: "Name2", issuerDescription: "Description2", guarantor: "Guarantor B", debtHolder: "Bond Holder C" + }); + + ICMTATCreditEvents.CreditEvents internal creditEventSample1 = + ICMTATCreditEvents.CreditEvents({flagDefault: false, flagRedeemed: true, rating: "AAA"}); + + ICMTATCreditEvents.CreditEvents internal creditEventSample2 = + ICMTATCreditEvents.CreditEvents({flagDefault: true, flagRedeemed: false, rating: "BBB"}); + + function setUp() public { + debtSample = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + + _deployEngine(); + + ICMTATConstructor.ERC20Attributes memory erc20Attributes = + ICMTATConstructor.ERC20Attributes("CMTA Token", "CMTAT", 0); + ICMTATConstructor.BaseModuleAttributes memory baseModuleAttributes = ICMTATConstructor.BaseModuleAttributes( + "CMTAT_ISIN", IERC1643CMTAT.DocumentInfo("terms", "https://cmta.ch", bytes32(0)), "CMTAT_info" + ); + ICMTATConstructor.Engine memory engines = + ICMTATConstructor.Engine(IRuleEngine(AddressZero), ISnapshotEngine(AddressZero), IERC1643(AddressZero)); + cmtat = new CMTATStandaloneDebt(admin, erc20Attributes, baseModuleAttributes, engines); + } + + function _deployEngine() internal virtual; + function _expectUnauthorizedDebtRevert(address caller) internal virtual; + function _expectUnauthorizedCreditEventsRevert(address caller) internal virtual; + + /*////////////////////////////////////////////////////////////// + DEPLOYMENT + ///////////////////////////////////////*/ + + function testDeploy() public virtual; + + /*////////////////////////////////////////////////////////////// + Set/Get Debt + ///////////////////////////////////////*/ + + function testSetDebtAsAdmin() public { + vm.prank(admin); + debtEngine.setDebt(testContract, debtSample); + + ICMTATDebt.DebtInformation memory d = debtEngine.debt(testContract); + assertEq(d.debtInstrument.interestRate, 5); + assertEq(d.debtInstrument.parValue, 1000); + } + + function testCanAdminSetCreditEvents() public { + vm.prank(admin); + debtEngine.setCreditEvents(testContract, creditEventSample); + + ICMTATCreditEvents.CreditEvents memory credit = debtEngine.creditEvents(testContract); + assertEq(credit.flagDefault, false); + assertEq(credit.flagRedeemed, true); + assertEq(credit.rating, "AAA"); + } + + function testSetDebtsBatchAsAdmin() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + vm.prank(admin); + debtEngine.setDebtBatch(contracts, debts); + + ICMTATDebt.DebtInformation memory debt1 = debtEngine.debt(testContract1); + assertEq(debt1.debtInstrument.interestRate, 5); + assertEq(debt1.debtInstrument.parValue, 1000); + + ICMTATDebt.DebtInformation memory debt2 = debtEngine.debt(testContract2); + assertEq(debt2.debtInstrument.interestRate, 6); + assertEq(debt2.debtInstrument.parValue, 2000); + } + + function testSetCreditEventsBatchAsAdmin() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + vm.prank(admin); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + + ICMTATCreditEvents.CreditEvents memory credit1 = debtEngine.creditEvents(testContract1); + assertEq(credit1.flagDefault, false); + assertEq(credit1.rating, "AAA"); + + ICMTATCreditEvents.CreditEvents memory credit2 = debtEngine.creditEvents(testContract2); + assertEq(credit2.flagDefault, true); + assertEq(credit2.rating, "BBB"); + } + + /*////////////////////////////////////////////////////////////// + CMTAT Integration + ///////////////////////////////////////*/ + + function testCanReturnCMTATDebt() public { + vm.prank(admin); + debtEngine.setDebt(address(cmtat), debtSample); + + vm.prank(admin); + cmtat.setDebtEngine(debtEngine); + + ICMTATDebt.DebtInformation memory d = cmtat.debt(); + assertEq(d.debtInstrument.parValue, 1000); + } + + function testCanReturnCMTATCreditEvents() public { + vm.prank(admin); + debtEngine.setCreditEvents(address(cmtat), creditEventSample); + + vm.prank(admin); + cmtat.setDebtEngine(debtEngine); + + vm.prank(attacker); + ICMTATCreditEvents.CreditEvents memory credit = cmtat.creditEvents(); + assertEq(credit.flagRedeemed, true); + } + + /*////////////////////////////////////////////////////////////// + INVALID PARAMETER + ///////////////////////////////////////*/ + + function testSetDebtsBatchLengthMismatch() public { + address[] memory contracts = new address[](1); + contracts[0] = testContract1; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); + debtEngine.setDebtBatch(contracts, debts); + } + + function testSetCreditEventsBatchLengthMismatch() public { + address[] memory contracts = new address[](1); + contracts[0] = testContract1; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(InvalidInputLength.selector)); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + } + + function testCannotSetDebtForAddressZero() public { + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(SmartContractWithAddressZeroNotAllowed.selector)); + debtEngine.setDebt(AddressZero, debtSample); + } + + function testCannotSetCreditEventsForAddressZero() public { + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(SmartContractWithAddressZeroNotAllowed.selector)); + debtEngine.setCreditEvents(AddressZero, creditEventSample); + } + + function testCannotSetDebtBatchWithAddressZero() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = AddressZero; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(SmartContractWithAddressZeroNotAllowed.selector)); + debtEngine.setDebtBatch(contracts, debts); + } + + function testCannotSetCreditEventsBatchWithAddressZero() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = AddressZero; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(SmartContractWithAddressZeroNotAllowed.selector)); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + } + + /*////////////////////////////////////////////////////////////// + EVENTS + ///////////////////////////////////////*/ + + function testSetDebtEmitsEvent() public { + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit DebtEngine.DebtSet(testContract); + debtEngine.setDebt(testContract, debtSample); + } + + function testSetCreditEventsEmitsEvent() public { + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit DebtEngine.CreditEventsSet(testContract); + debtEngine.setCreditEvents(testContract, creditEventSample); + } + + function testSetDebtBatchEmitsEvents() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit DebtEngine.DebtSet(testContract1); + vm.expectEmit(true, false, false, false); + emit DebtEngine.DebtSet(testContract2); + debtEngine.setDebtBatch(contracts, debts); + } + + function testSetCreditEventsBatchEmitsEvents() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + vm.prank(admin); + vm.expectEmit(true, false, false, false); + emit DebtEngine.CreditEventsSet(testContract1); + vm.expectEmit(true, false, false, false); + emit DebtEngine.CreditEventsSet(testContract2); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + } + + /*////////////////////////////////////////////////////////////// + HAS DEBT / HAS CREDIT EVENTS + ///////////////////////////////////////*/ + + function testHasDebtReturnsFalseWhenNotSet() public view { + assertEq(debtEngine.hasDebt(testContract), false); + } + + function testHasDebtReturnsTrueWhenSet() public { + vm.prank(admin); + debtEngine.setDebt(testContract, debtSample); + + assertEq(debtEngine.hasDebt(testContract), true); + } + + function testHasCreditEventsReturnsFalseWhenNotSet() public view { + assertEq(debtEngine.hasCreditEvents(testContract), false); + } + + function testHasCreditEventsReturnsTrueWhenSet() public { + vm.prank(admin); + debtEngine.setCreditEvents(testContract, creditEventSample); + + assertEq(debtEngine.hasCreditEvents(testContract), true); + } + + function testHasDebtBatchSetsFlags() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + assertEq(debtEngine.hasDebt(testContract1), false); + assertEq(debtEngine.hasDebt(testContract2), false); + + vm.prank(admin); + debtEngine.setDebtBatch(contracts, debts); + + assertEq(debtEngine.hasDebt(testContract1), true); + assertEq(debtEngine.hasDebt(testContract2), true); + } + + function testHasCreditEventsBatchSetsFlags() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + assertEq(debtEngine.hasCreditEvents(testContract1), false); + assertEq(debtEngine.hasCreditEvents(testContract2), false); + + vm.prank(admin); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + + assertEq(debtEngine.hasCreditEvents(testContract1), true); + assertEq(debtEngine.hasCreditEvents(testContract2), true); + } +} diff --git a/test/DebtEngineOwnable.t.sol b/test/DebtEngineOwnable.t.sol new file mode 100644 index 0000000..741b0ee --- /dev/null +++ b/test/DebtEngineOwnable.t.sol @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MPL-2.0 +pragma solidity ^0.8.20; + +import {DebtEngineBaseTest} from "./DebtEngineBase.t.sol"; +import {DebtEngineOwnable} from "../src/access/DebtEngineOwnable.sol"; +import {Ownable} from "OZ/access/Ownable.sol"; +import {ICMTATDebt, ICMTATCreditEvents} from "CMTAT/interfaces/tokenization/ICMTAT.sol"; + +contract DebtEngineOwnableTest is DebtEngineBaseTest { + DebtEngineOwnable private debtEngineOwn; + + function _deployEngine() internal override { + debtEngineOwn = new DebtEngineOwnable(admin, AddressZero); + debtEngine = debtEngineOwn; + } + + function _expectUnauthorizedDebtRevert(address caller) internal override { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + } + + function _expectUnauthorizedCreditEventsRevert(address caller) internal override { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + } + + /*////////////////////////////////////////////////////////////// + DEPLOYMENT + ///////////////////////////////////////*/ + + function testDeploy() public override { + address forwarder = address(0x1); + debtEngineOwn = new DebtEngineOwnable(admin, forwarder); + + // Forwarder + assertEq(debtEngineOwn.isTrustedForwarder(forwarder), true); + // owner + assertEq(debtEngineOwn.owner(), admin); + // zero address + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, AddressZero)); + new DebtEngineOwnable(AddressZero, forwarder); + } + + /*////////////////////////////////////////////////////////////// + Access control — unauthorized + ///////////////////////////////////////*/ + + function testCannotNonOwnerSetDebt() public { + vm.prank(attacker); + _expectUnauthorizedDebtRevert(attacker); + debtEngine.setDebt(testContract, debtSample); + } + + function testCannotNonOwnerSetCreditEvents() public { + vm.prank(attacker); + _expectUnauthorizedCreditEventsRevert(attacker); + debtEngine.setCreditEvents(testContract, creditEventSample); + } + + function testSetDebtsBatchAsNonOwnerFails() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATDebt.DebtInformation[] memory debts = new ICMTATDebt.DebtInformation[](2); + debts[0] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier1, debtInstrument: debtSample1}); + debts[1] = ICMTATDebt.DebtInformation({debtIdentifier: debtIdentifier2, debtInstrument: debtSample2}); + + vm.prank(attacker); + _expectUnauthorizedDebtRevert(attacker); + debtEngine.setDebtBatch(contracts, debts); + } + + function testSetCreditEventsBatchAsNonOwnerFails() public { + address[] memory contracts = new address[](2); + contracts[0] = testContract1; + contracts[1] = testContract2; + + ICMTATCreditEvents.CreditEvents[] memory creditEventsList = new ICMTATCreditEvents.CreditEvents[](2); + creditEventsList[0] = creditEventSample1; + creditEventsList[1] = creditEventSample2; + + vm.prank(attacker); + _expectUnauthorizedCreditEventsRevert(attacker); + debtEngine.setCreditEventsBatch(contracts, creditEventsList); + } + + /*////////////////////////////////////////////////////////////// + Ownable-specific: transferOwnership, renounceOwnership + ///////////////////////////////////////*/ + + function testTransferOwnership() public { + address newOwner = address(0x99); + + vm.prank(admin); + debtEngineOwn.transferOwnership(newOwner); + assertEq(debtEngineOwn.owner(), newOwner); + + // New owner can set debt + vm.prank(newOwner); + debtEngine.setDebt(testContract, debtSample); + + ICMTATDebt.DebtInformation memory d = debtEngine.debt(testContract); + assertEq(d.debtInstrument.interestRate, 5); + + // Old owner can no longer set debt + vm.prank(admin); + _expectUnauthorizedDebtRevert(admin); + debtEngine.setDebt(testContract, debtSample); + } + + function testRenounceOwnership() public { + vm.prank(admin); + debtEngineOwn.renounceOwnership(); + assertEq(debtEngineOwn.owner(), AddressZero); + + // No one can set debt anymore + vm.prank(admin); + _expectUnauthorizedDebtRevert(admin); + debtEngine.setDebt(testContract, debtSample); + } +}