diff --git a/contracts/mocks/token/ERC20uRWAMock.sol b/contracts/mocks/token/ERC20uRWAMock.sol new file mode 100644 index 00000000..dbf6be6c --- /dev/null +++ b/contracts/mocks/token/ERC20uRWAMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20uRWA} from "../../token/ERC20/extensions/ERC20uRWA.sol"; +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +abstract contract ERC20uRWAMock is ERC20uRWA, AccessControl { + bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE"); + bytes32 public constant ENFORCER_ROLE = keccak256("ENFORCER_ROLE"); + + constructor(address freezer, address enforcer) { + _grantRole(FREEZER_ROLE, freezer); + _grantRole(ENFORCER_ROLE, enforcer); + } + + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(ERC20uRWA, AccessControl) returns (bool) { + return super.supportsInterface(interfaceId); + } + + function _checkEnforcer(address, address, uint256) internal view override onlyRole(ENFORCER_ROLE) {} + + function _checkFreezer(address, uint256) internal view override onlyRole(FREEZER_ROLE) {} +} diff --git a/contracts/token/ERC20/extensions/ERC20Allowlist.sol b/contracts/token/ERC20/extensions/ERC20Allowlist.sol index dd4351dc..508fc028 100644 --- a/contracts/token/ERC20/extensions/ERC20Allowlist.sol +++ b/contracts/token/ERC20/extensions/ERC20Allowlist.sol @@ -15,6 +15,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; * on its behalf if {_allowUser} was not called with such account as an * argument. Similarly, the account will be disallowed again if * {_disallowUser} is called. + * + * IMPORTANT: Deprecated. Use {ERC20Restricted} instead. */ abstract contract ERC20Allowlist is ERC20 { /** diff --git a/contracts/token/ERC20/extensions/ERC20Blocklist.sol b/contracts/token/ERC20/extensions/ERC20Blocklist.sol index 92efe93c..726e2cf4 100644 --- a/contracts/token/ERC20/extensions/ERC20Blocklist.sol +++ b/contracts/token/ERC20/extensions/ERC20Blocklist.sol @@ -15,6 +15,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; * on its behalf if {_blockUser} was not called with such account as an * argument. Similarly, the account will be unblocked again if * {_unblockUser} is called. + * + * IMPORTANT: Deprecated. Use {ERC20Restricted} instead. */ abstract contract ERC20Blocklist is ERC20 { /** diff --git a/contracts/token/ERC20/extensions/ERC20Custodian.sol b/contracts/token/ERC20/extensions/ERC20Custodian.sol index cb1fcb7e..8acbf85b 100644 --- a/contracts/token/ERC20/extensions/ERC20Custodian.sol +++ b/contracts/token/ERC20/extensions/ERC20Custodian.sol @@ -16,6 +16,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; * The frozen balance is not available for transfers or approvals * to other entities to operate on its behalf if. The frozen balance * can be reduced by calling {freeze} again with a lower amount. + * + * IMPORTANT: Deprecated. Use {ERC20Freezable} instead. */ abstract contract ERC20Custodian is ERC20 { /** diff --git a/contracts/token/ERC20/extensions/ERC20Freezable.sol b/contracts/token/ERC20/extensions/ERC20Freezable.sol new file mode 100644 index 00000000..ff87df2e --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20Freezable.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {IERC7943} from "../../../interfaces/IERC7943.sol"; + +/** + * @dev Extension of {ERC20} that allows to implement a freezing + * mechanism that can be managed by an authorized account with the + * {_freezeTokens} and {_unfreezeTokens} functions. + * + * The freezing mechanism provides the guarantee to the contract owner + * (e.g. a DAO or a well-configured multisig) that a specific amount + * of tokens held by an account won't be transferable until those + * tokens are unfrozen using {_unfreezeTokens}. + */ +abstract contract ERC20Freezable is ERC20 { + /// @dev Frozen amount of tokens per address. + mapping(address account => uint256) private _frozenBalances; + + /// @dev The operation failed because the user has insufficient unfrozen balance. + error ERC20InsufficientUnfrozenBalance(address user, uint256 needed, uint256 available); + + /// @dev Returns the frozen balance of an account. + function frozen(address account) public view virtual returns (uint256) { + return _frozenBalances[account]; + } + + /// @dev Returns the available (unfrozen) balance of an account. Up to {balanceOf}. + function available(address account) public view virtual returns (uint256) { + (bool success, uint256 unfrozen) = Math.trySub(balanceOf(account), _frozenBalances[account]); + return success ? unfrozen : 0; + } + + /// @dev Internal function to set the frozen token amount for a user. + function _setFrozen(address user, uint256 amount) internal virtual { + _frozenBalances[user] = amount; + emit IERC7943.Frozen(user, 0, amount); + } + + /** + * @dev See {ERC20-_update}. + * + * Requirements: + * + * * `from` must have sufficient unfrozen balance. + */ + function _update(address from, address to, uint256 value) internal virtual override { + if (from != address(0)) { + uint256 unfrozen = available(from); + require(unfrozen >= value, ERC20InsufficientUnfrozenBalance(from, value, unfrozen)); + } + super._update(from, to, value); + } + + // We don't check frozen balance for approvals since the actual transfer + // will be checked in _update. This allows for more flexible approval patterns. +} diff --git a/contracts/token/ERC20/extensions/ERC20Restricted.sol b/contracts/token/ERC20/extensions/ERC20Restricted.sol new file mode 100644 index 00000000..78b81183 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20Restricted.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @dev Extension of {ERC20} that allows to implement user account transfer restrictions + * through the {isUserAllowed} function. Inspired by https://eips.ethereum.org/EIPS/eip-7943[EIP-7943]. + * + * By default, each account has no explicit restriction. The {isUserAllowed} function acts as + * a blocklist. Developers can override {isUserAllowed} to check that `restriction == ALLOWED` + * to implement an allowlist. + */ +abstract contract ERC20Restricted is ERC20 { + enum Restriction { + DEFAULT, // User has no explicit restriction + BLOCKED, // User is explicitly blocked + ALLOWED // User is explicitly allowed + } + + mapping(address account => Restriction) private _restrictions; + + /// @dev Emitted when a user account's restriction is updated. + event UserRestrictionsUpdated(address indexed account, Restriction restriction); + + /// @dev The operation failed because the user account is restricted. + error ERC20UserRestricted(address account); + + /// @dev Returns the restriction of a user account. + function getRestriction(address account) public view virtual returns (Restriction) { + return _restrictions[account]; + } + + /** + * @dev Returns whether a user account is allowed to interact with the token. + * + * Default implementation only disallows explicitly BLOCKED accounts (i.e. a blocklist). + * + * To convert into an allowlist, override as: + * + * ```solidity + * function isUserAllowed(address account) public view virtual override returns (bool) { + * return getRestriction(account) == Restriction.ALLOWED; + * } + * ``` + */ + function isUserAllowed(address account) public view virtual returns (bool) { + return getRestriction(account) != Restriction.BLOCKED; // i.e. DEFAULT && ALLOWED + } + + /** + * @dev See {ERC20-_update}. Enforces restriction transfers (excluding minting and burning). + * + * Requirements: + * + * * `from` must be allowed to transfer tokens (see {isUserAllowed}). + * * `to` must be allowed to receive tokens (see {isUserAllowed}). + */ + function _update(address from, address to, uint256 value) internal virtual override { + if (from != address(0)) _checkRestriction(from); // Not minting + if (to != address(0)) _checkRestriction(to); // Not burning + super._update(from, to, value); + } + + // We don't check restrictions for approvals since the actual transfer + // will be checked in _update. This allows for more flexible approval patterns. + + /// @dev Updates the restriction of a user account. + function _setRestriction(address account, Restriction restriction) internal virtual { + if (getRestriction(account) != restriction) { + _restrictions[account] = restriction; + emit UserRestrictionsUpdated(account, restriction); + } // no-op if restriction is unchanged + } + + /// @dev Convenience function to block a user account (set to BLOCKED). + function _blockUser(address account) internal virtual { + _setRestriction(account, Restriction.BLOCKED); + } + + /// @dev Convenience function to allow a user account (set to ALLOWED). + function _allowUser(address account) internal virtual { + _setRestriction(account, Restriction.ALLOWED); + } + + /// @dev Convenience function to reset a user account to default restriction. + function _resetUser(address account) internal virtual { + _setRestriction(account, Restriction.DEFAULT); + } + + /// @dev Checks if a user account is restricted. Reverts with {ERC20Restricted} if so. + function _checkRestriction(address account) internal view virtual { + require(isUserAllowed(account), ERC20UserRestricted(account)); + } +} diff --git a/contracts/token/ERC20/extensions/ERC20uRWA.sol b/contracts/token/ERC20/extensions/ERC20uRWA.sol new file mode 100644 index 00000000..c6023914 --- /dev/null +++ b/contracts/token/ERC20/extensions/ERC20uRWA.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.26; + +import {IERC7943} from "../../../interfaces/IERC7943.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; +import {ERC20Freezable} from "./ERC20Freezable.sol"; +import {ERC20Restricted} from "./ERC20Restricted.sol"; + +/** + * @dev Extension of {ERC20} according to https://eips.ethereum.org/EIPS/eip-7943[EIP-7943]. + * + * Combines standard ERC-20 functionality with RWA-specific features like user restrictions, + * asset freezing, and forced asset transfers. + */ +abstract contract ERC20uRWA is ERC20, ERC165, ERC20Freezable, ERC20Restricted, IERC7943 { + /// @inheritdoc ERC20Restricted + function isUserAllowed(address user) public view virtual override(IERC7943, ERC20Restricted) returns (bool) { + return super.isUserAllowed(user); + } + + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) { + return interfaceId == type(IERC7943).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev See {IERC7943-isTransferAllowed}. + * + * CAUTION: This function is only meant for external use. Overriding it will not apply the new checks to + * the internal {_update} function. Consider overriding {_update} accordingly to keep both functions in sync. + */ + function isTransferAllowed(address from, address to, uint256, uint256 amount) external view virtual returns (bool) { + return (amount <= available(from) && isUserAllowed(from) && isUserAllowed(to)); + } + + /// @inheritdoc IERC7943 + function getFrozen(address user, uint256) public view virtual returns (uint256 amount) { + return frozen(user); + } + + /** + * @dev See {IERC7943-setFrozen}. + * + * NOTE: The `amount` is capped to the balance of the `user` to ensure the {IERC7943-Frozen} event + * emits values that consistently reflect the actual amount of tokens that are frozen. + */ + function setFrozen(address user, uint256, uint256 amount) public virtual { + uint256 actualAmount = Math.min(amount, balanceOf(user)); + _checkFreezer(user, actualAmount); + _setFrozen(user, actualAmount); + } + + /** + * @dev See {IERC7943-forceTransfer}. + * + * NOTE: Allows to bypass the freezing mechanism. However, in cases where the balance after + * the transfer is less than the frozen balance, the frozen balance is adjusted to the new balance. + */ + function forceTransfer(address from, address to, uint256, uint256 amount) public virtual { + _checkEnforcer(from, to, amount); + require(isUserAllowed(to), ERC7943NotAllowedUser(to)); + + // Update frozen balance if needed. ERC-7943 requires that balance is unfrozen first and then send the tokens. + uint256 currentFrozen = frozen(from); + uint256 newBalance; + unchecked { + // Safe because ERC20._update will check that balanceOf(from) >= amount + newBalance = balanceOf(from) - amount; + } + if (currentFrozen > newBalance) { + _setFrozen(from, newBalance); + } + + ERC20._update(from, to, amount); // Explicit raw update to bypass all restrictions + emit ForcedTransfer(from, to, 0, amount); + } + + /// @inheritdoc ERC20 + function _update( + address from, + address to, + uint256 amount + ) internal virtual override(ERC20, ERC20Freezable, ERC20Restricted) { + // Note: We rely on the inherited _update chain (ERC20Freezable + ERC20Restricted) to enforce + // the same restrictions that isTransferAllowed would check. This avoids duplicate validation + // while maintaining consistency between external queries and internal transfer logic. + super._update(from, to, amount); + } + + /** + * @dev Internal function to check if the `enforcer` is allowed to forcibly transfer the `amount` of `tokens`. + * + * Example usage with {AccessControl-onlyRole}: + * + * ```solidity + * function _checkEnforcer(address from, address to, uint256 amount) internal view override onlyRole(ENFORCER_ROLE) {} + * ``` + */ + function _checkEnforcer(address from, address to, uint256 amount) internal view virtual; + + /** + * @dev Internal function to check if the `freezer` is allowed to freeze the `amount` of `tokens`. + * + * Example usage with {AccessControl-onlyRole}: + * + * ```solidity + * function _checkFreezer(address user, uint256 amount) internal view override onlyRole(FREEZER_ROLE) {} + * ``` + */ + function _checkFreezer(address user, uint256 amount) internal view virtual; +} diff --git a/test/token/ERC20/extensions/ERC20Freezable.test.js b/test/token/ERC20/extensions/ERC20Freezable.test.js new file mode 100644 index 00000000..b69e5df6 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20Freezable.test.js @@ -0,0 +1,260 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const name = 'My Token'; +const symbol = 'MTKN'; +const initialSupply = 100n; + +async function fixture() { + const [holder, recipient, approved] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20Freezable', [name, symbol]); + await token.$_mint(holder, initialSupply); + + return { holder, recipient, approved, token }; +} + +describe('ERC20Freezable', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('freeze management', function () { + it('returns zero frozen balance for new users', async function () { + await expect(this.token.frozen(this.holder)).to.eventually.equal(0); + }); + + it('returns full balance as available for unfrozen users', async function () { + await expect(this.token.available(this.holder)).to.eventually.equal(initialSupply); + }); + + it('allows setting frozen amount', async function () { + const frozenAmount = 50n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(frozenAmount); + await expect(this.token.available(this.holder)).to.eventually.equal(initialSupply - frozenAmount); + }); + + it('allows updating frozen amount', async function () { + const firstAmount = 30n; + const secondAmount = 70n; + + await this.token.$_setFrozen(this.holder, firstAmount); + await expect(this.token.frozen(this.holder)).to.eventually.equal(firstAmount); + + await this.token.$_setFrozen(this.holder, secondAmount); + await expect(this.token.frozen(this.holder)).to.eventually.equal(secondAmount); + await expect(this.token.available(this.holder)).to.eventually.equal(initialSupply - secondAmount); + }); + + it('allows unfreezing by setting frozen amount to zero', async function () { + const frozenAmount = 60n; + await this.token.$_setFrozen(this.holder, frozenAmount); + await this.token.$_setFrozen(this.holder, 0n); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(0); + await expect(this.token.available(this.holder)).to.eventually.equal(initialSupply); + }); + + it('emits Frozen event when setting frozen amount', async function () { + const frozenAmount = 40n; + await expect(this.token.$_setFrozen(this.holder, frozenAmount)) + .to.emit(this.token, 'Frozen') + .withArgs(this.holder, 0, frozenAmount); + }); + }); + + describe('freezable token operations', function () { + describe('transfer', function () { + it('allows transfer when no tokens are frozen', async function () { + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-initialSupply, initialSupply], + ); + }); + + it('allows transfer when sufficient unfrozen balance available', async function () { + const frozenAmount = 30n; + const transferAmount = 50n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.connect(this.holder).transfer(this.recipient, transferAmount)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + + it('reverts when trying to transfer more than available unfrozen balance', async function () { + const frozenAmount = 60n; + const transferAmount = 50n; // Available: 100 - 60 = 40, trying to transfer 50 + + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.connect(this.holder).transfer(this.recipient, transferAmount)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, transferAmount, initialSupply - frozenAmount); + }); + + it('reverts when trying to transfer entire balance with some tokens frozen', async function () { + const frozenAmount = 1n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, initialSupply, initialSupply - frozenAmount); + }); + + it('allows transfer after unfreezing tokens', async function () { + const frozenAmount = 60n; + await this.token.$_setFrozen(this.holder, frozenAmount); + await this.token.$_setFrozen(this.holder, 0n); // Unfreeze + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-initialSupply, initialSupply], + ); + }); + }); + + describe('transfer from', function () { + const allowance = 40n; + + beforeEach(async function () { + await this.token.connect(this.holder).approve(this.approved, allowance); + }); + + it('allows transferFrom when sufficient unfrozen balance available', async function () { + const frozenAmount = 20n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect( + this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance), + ).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]); + }); + + it('reverts when trying to transferFrom more than available unfrozen balance', async function () { + const frozenAmount = 70n; // Available: 100 - 70 = 30, trying to transfer 40 + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, allowance, initialSupply - frozenAmount); + }); + + it('allows transferFrom after unfreezing sufficient tokens', async function () { + const frozenAmount = 70n; + await this.token.$_setFrozen(this.holder, frozenAmount); + await this.token.$_setFrozen(this.holder, 20n); // Reduce frozen amount + + await expect( + this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance), + ).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]); + }); + }); + + describe('mint', function () { + const value = 42n; + + it('allows minting to any account (no freeze restrictions on minting)', async function () { + const frozenAmount = 50n; + await this.token.$_setFrozen(this.recipient, frozenAmount); + + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + + it('updates available balance correctly after minting to frozen account', async function () { + const frozenAmount = 30n; + await this.token.$_setFrozen(this.recipient, frozenAmount); + await this.token.$_mint(this.recipient, value); + + await expect(this.token.frozen(this.recipient)).to.eventually.equal(frozenAmount); + await expect(this.token.available(this.recipient)).to.eventually.equal(value - frozenAmount); // 42 - 30 = 12 + }); + }); + + describe('burn', function () { + const value = 42n; + + it('allows burning when sufficient unfrozen balance available', async function () { + const frozenAmount = 20n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value); + }); + + it('reverts when trying to burn more than available unfrozen balance', async function () { + const frozenAmount = 70n; // Available: 100 - 70 = 30, trying to burn 42 + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.$_burn(this.holder, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, value, initialSupply - frozenAmount); + }); + + it('allows burning entire unfrozen balance', async function () { + const frozenAmount = 30n; + const availableBalance = initialSupply - frozenAmount; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.$_burn(this.holder, availableBalance)).to.changeTokenBalance( + this.token, + this.holder, + -availableBalance, + ); + }); + + it('updates available balance correctly after burning', async function () { + const frozenAmount = 40n; + const burnAmount = 30n; + await this.token.$_setFrozen(this.holder, frozenAmount); + await this.token.$_burn(this.holder, burnAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(frozenAmount); + await expect(this.token.available(this.holder)).to.eventually.equal(initialSupply - frozenAmount - burnAmount); + }); + }); + + describe('approve', function () { + const allowance = 40n; + + it('allows approval with frozen tokens (approvals are not restricted)', async function () { + const frozenAmount = 80n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + + it('allows approval even when all tokens are frozen', async function () { + await this.token.$_setFrozen(this.holder, initialSupply); + + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + }); + }); + + describe('edge cases', function () { + it('handles frozen amount greater than balance gracefully', async function () { + const frozenAmount = initialSupply + 50n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(frozenAmount); + await expect(this.token.available(this.holder)).to.eventually.equal(0); // Should not underflow + }); + + it('prevents any transfer when frozen amount exceeds balance', async function () { + const frozenAmount = initialSupply + 50n; + await this.token.$_setFrozen(this.holder, frozenAmount); + + await expect(this.token.connect(this.holder).transfer(this.recipient, 1n)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, 1n, 0); + }); + }); +}); diff --git a/test/token/ERC20/extensions/ERC20Restricted.test.js b/test/token/ERC20/extensions/ERC20Restricted.test.js new file mode 100644 index 00000000..f3ea0591 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20Restricted.test.js @@ -0,0 +1,240 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +const name = 'My Token'; +const symbol = 'MTKN'; +const initialSupply = 100n; + +async function fixture() { + const [holder, recipient, approved] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20Restricted', [name, symbol]); + await token.$_mint(holder, initialSupply); + + return { holder, recipient, approved, token }; +} + +describe('ERC20Restricted', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('restriction management', function () { + it('returns DEFAULT restriction for new users', async function () { + await expect(this.token.getRestriction(this.holder)).to.eventually.equal(0); // DEFAULT + }); + + it('allows users with DEFAULT restriction', async function () { + await expect(this.token.isUserAllowed(this.holder)).to.eventually.equal(true); + }); + + it('allows users with ALLOWED status', async function () { + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + await expect(this.token.getRestriction(this.holder)).to.eventually.equal(2); // ALLOWED + await expect(this.token.isUserAllowed(this.holder)).to.eventually.equal(true); + }); + + it('blocks users with BLOCKED status', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await expect(this.token.getRestriction(this.holder)).to.eventually.equal(1); // BLOCKED + await expect(this.token.isUserAllowed(this.holder)).to.eventually.equal(false); + }); + + it('resets user to DEFAULT restriction', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.$_resetUser(this.holder); // Sets to DEFAULT + await expect(this.token.getRestriction(this.holder)).to.eventually.equal(0); // DEFAULT + await expect(this.token.isUserAllowed(this.holder)).to.eventually.equal(true); + }); + + it('emits UserRestrictionsUpdated event when restriction changes', async function () { + await expect(this.token.$_blockUser(this.holder)) + .to.emit(this.token, 'UserRestrictionsUpdated') + .withArgs(this.holder, 1); // BLOCKED + + await expect(this.token.$_allowUser(this.holder)) + .to.emit(this.token, 'UserRestrictionsUpdated') + .withArgs(this.holder, 2); // ALLOWED + + await expect(this.token.$_resetUser(this.holder)) + .to.emit(this.token, 'UserRestrictionsUpdated') + .withArgs(this.holder, 0); // DEFAULT + }); + + it('does not emit event when restriction is unchanged', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await expect(this.token.$_blockUser(this.holder)).to.not.emit(this.token, 'UserRestrictionsUpdated'); + }); + }); + + describe('restricted token operations', function () { + describe('transfer', function () { + it('allows transfer when sender and recipient have DEFAULT restriction', async function () { + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-initialSupply, initialSupply], + ); + }); + + it('allows transfer when sender and recipient are ALLOWED', async function () { + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + await this.token.$_allowUser(this.recipient); // Sets to ALLOWED + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-initialSupply, initialSupply], + ); + }); + + it('reverts when sender is BLOCKED', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('reverts when recipient is BLOCKED', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('allows transfer when restricted user is then unrestricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.$_resetUser(this.holder); // Sets back to DEFAULT + + await expect(this.token.connect(this.holder).transfer(this.recipient, initialSupply)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-initialSupply, initialSupply], + ); + }); + }); + + describe('transfer from', function () { + const allowance = 40n; + + beforeEach(async function () { + await this.token.connect(this.holder).approve(this.approved, allowance); + }); + + it('allows transferFrom when sender and recipient are allowed', async function () { + await expect( + this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance), + ).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]); + }); + + it('reverts when sender is BLOCKED', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('reverts when recipient is BLOCKED', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('allows transferFrom when restricted user is then unrestricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + + await expect( + this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance), + ).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]); + }); + }); + + describe('mint', function () { + const value = 42n; + + it('allows minting to DEFAULT users', async function () { + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + + it('allows minting to ALLOWED users', async function () { + await this.token.$_allowUser(this.recipient); // Sets to ALLOWED + + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + + it('reverts when trying to mint to BLOCKED user', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.$_mint(this.recipient, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('allows minting when restricted user is then unrestricted', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + await this.token.$_resetUser(this.recipient); // Sets back to DEFAULT + + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + }); + + describe('burn', function () { + const value = 42n; + + it('allows burning from DEFAULT users', async function () { + await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value); + }); + + it('allows burning from ALLOWED users', async function () { + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + + await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value); + }); + + it('reverts when trying to burn from BLOCKED user', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.$_burn(this.holder, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('allows burning when restricted user is then unrestricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + + await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value); + }); + }); + + describe('approve', function () { + const allowance = 40n; + + it('allows approval from DEFAULT users', async function () { + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + + it('allows approval from ALLOWED users', async function () { + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + + it('allows approval from BLOCKED users (approvals are not restricted)', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + }); + }); +}); diff --git a/test/token/ERC20/extensions/ERC20uRWA.test.js b/test/token/ERC20/extensions/ERC20uRWA.test.js new file mode 100644 index 00000000..4547d2c9 --- /dev/null +++ b/test/token/ERC20/extensions/ERC20uRWA.test.js @@ -0,0 +1,359 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const { + shouldSupportInterfaces, +} = require('@openzeppelin/contracts/test/utils/introspection/SupportsInterface.behavior'); + +const name = 'My uRWA Token'; +const symbol = 'uRWA'; +const initialSupply = 100n; + +async function fixture() { + const [holder, recipient, approved, freezer, enforcer, other] = await ethers.getSigners(); + + const token = await ethers.deployContract('$ERC20uRWAMock', [name, symbol, freezer.address, enforcer.address]); + await token.$_mint(holder, initialSupply); + + return { holder, recipient, approved, freezer, enforcer, other, token }; +} + +describe('ERC20uRWA', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('ERC165', function () { + shouldSupportInterfaces({ + ERC7943: [ + 'isTransferAllowed(address,address,uint256,uint256)', + 'getFrozen(address,uint256)', + 'setFrozen(address,uint256,uint256)', + 'forceTransfer(address,address,uint256,uint256)', + 'isUserAllowed(address)', + ], + }); + }); + + describe('combined restriction and freezing', function () { + it('allows transfer when user is allowed and has sufficient unfrozen balance', async function () { + const transferAmount = 30n; + + await expect(this.token.connect(this.holder).transfer(this.recipient, transferAmount)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + + it('reverts when sender is restricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.connect(this.holder).transfer(this.recipient, 30n)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('reverts when recipient is restricted', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.connect(this.holder).transfer(this.recipient, 30n)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('reverts when sender has insufficient unfrozen balance', async function () { + const frozenAmount = 80n; + const transferAmount = 30n; // Available: 100 - 80 = 20, trying to transfer 30 + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + await expect(this.token.connect(this.holder).transfer(this.recipient, transferAmount)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, transferAmount, initialSupply - frozenAmount); + }); + + it('allows transfer when both restrictions and freezing allow it', async function () { + const frozenAmount = 20n; + const transferAmount = 30n; // Available: 100 - 20 = 80, transferring 30 + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + await this.token.$_allowUser(this.holder); // Sets to ALLOWED + await this.token.$_allowUser(this.recipient); // Sets to ALLOWED + + await expect(this.token.connect(this.holder).transfer(this.recipient, transferAmount)).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + }); + + describe('isTransferAllowed', function () { + it('returns true when all conditions are met', async function () { + const amount = 30n; + await expect(this.token.isTransferAllowed(this.holder, this.recipient, 0, amount)).to.eventually.equal(true); + }); + + it('returns false when sender is restricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.isTransferAllowed(this.holder, this.recipient, 0, 30n)).to.eventually.equal(false); + }); + + it('returns false when recipient is restricted', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.isTransferAllowed(this.holder, this.recipient, 0, 30n)).to.eventually.equal(false); + }); + + it('returns false when amount exceeds available balance', async function () { + const frozenAmount = 80n; + const transferAmount = 30n; // Available: 100 - 80 = 20 + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + await expect(this.token.isTransferAllowed(this.holder, this.recipient, 0, transferAmount)).to.eventually.equal( + false, + ); + }); + }); + + describe('freezing functionality', function () { + describe('getFrozen', function () { + it('returns zero for users with no frozen tokens', async function () { + await expect(this.token.getFrozen(this.holder, 0)).to.eventually.equal(0); + }); + + it('returns correct frozen amount', async function () { + const frozenAmount = 40n; + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + await expect(this.token.getFrozen(this.holder, 0)).to.eventually.equal(frozenAmount); + }); + }); + + describe('setFrozen', function () { + it('allows freezer to set frozen amount', async function () { + const frozenAmount = 50n; + + await expect(this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount)) + .to.emit(this.token, 'Frozen') + .withArgs(this.holder, 0, frozenAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(frozenAmount); + }); + + it('reverts when non-freezer tries to set frozen amount', async function () { + await expect(this.token.connect(this.other).setFrozen(this.holder, 0, 50n)).to.be.revertedWithCustomError( + this.token, + 'AccessControlUnauthorizedAccount', + ); + }); + + it('caps frozen amount to user balance when trying to freeze more than balance', async function () { + const requestedFrozenAmount = initialSupply + 10n; + const expectedFrozenAmount = initialSupply; // Should be capped to balance + + await expect(this.token.connect(this.freezer).setFrozen(this.holder, 0, requestedFrozenAmount)) + .to.emit(this.token, 'Frozen') + .withArgs(this.holder, 0, expectedFrozenAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(expectedFrozenAmount); + }); + + it('allows freezer to update frozen amount', async function () { + await this.token.connect(this.freezer).setFrozen(this.holder, 0, 30n); + await this.token.connect(this.freezer).setFrozen(this.holder, 0, 70n); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(70n); + }); + + it('allows freezer to unfreeze tokens', async function () { + await this.token.connect(this.freezer).setFrozen(this.holder, 0, 60n); + await this.token.connect(this.freezer).setFrozen(this.holder, 0, 0n); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(0); + }); + }); + }); + + describe('force transfer functionality', function () { + describe('forceTransfer', function () { + it('allows enforcer to force transfer', async function () { + const transferAmount = 40n; + + const tx = this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.emit(this.token, 'ForcedTransfer').withArgs(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + + it('reverts when non-enforcer tries to force transfer', async function () { + await expect( + this.token.connect(this.other).forceTransfer(this.holder, this.recipient, 0, 40n), + ).to.be.revertedWithCustomError(this.token, 'AccessControlUnauthorizedAccount'); + }); + + it('reverts when forcing transfer to restricted recipient', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, 40n)) + .to.be.revertedWithCustomError(this.token, 'ERC7943NotAllowedUser') + .withArgs(this.recipient); + }); + + it('allows force transfer from restricted sender', async function () { + const transferAmount = 40n; + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + const tx = this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.emit(this.token, 'ForcedTransfer').withArgs(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + + it('allows force transfer of frozen tokens', async function () { + const frozenAmount = 60n; + const transferAmount = 80n; // More than available (40), but should work with force transfer + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + const tx = this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.emit(this.token, 'ForcedTransfer').withArgs(this.holder, this.recipient, 0, transferAmount); + await expect(tx).to.changeTokenBalances( + this.token, + [this.holder, this.recipient], + [-transferAmount, transferAmount], + ); + }); + + it('updates frozen balance when force transferring frozen tokens', async function () { + const frozenAmount = 80n; + const transferAmount = 70n; + const expectedRemainingFrozen = initialSupply - transferAmount; // 100 - 70 = 30 + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + await this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, transferAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(expectedRemainingFrozen); + }); + + it('does not update frozen balance when force transferring without affecting frozen tokens', async function () { + const frozenAmount = 30n; + const transferAmount = 20n; // Less than available (70) + + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + await this.token.connect(this.enforcer).forceTransfer(this.holder, this.recipient, 0, transferAmount); + + await expect(this.token.frozen(this.holder)).to.eventually.equal(frozenAmount); + }); + }); + }); + + describe('minting and burning', function () { + describe('mint', function () { + const value = 42n; + + it('allows minting to allowed users', async function () { + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + + it('reverts when minting to restricted user', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.$_mint(this.recipient, value)) + // ERC7943NotAllowedUser is not required by ERC-7943 + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('allows minting to user with frozen tokens', async function () { + await this.token.$_mint(this.recipient, 20n); + await this.token.connect(this.freezer).setFrozen(this.recipient, 0, 20n); + + await expect(this.token.$_mint(this.recipient, value)).to.changeTokenBalance(this.token, this.recipient, value); + }); + }); + + describe('burn', function () { + const value = 42n; + + it('allows burning from users with sufficient unfrozen balance', async function () { + await expect(this.token.$_burn(this.holder, value)).to.changeTokenBalance(this.token, this.holder, -value); + }); + + it('reverts when burning from restricted user', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.$_burn(this.holder, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('reverts when burning more than unfrozen balance', async function () { + const frozenAmount = 70n; + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + await expect(this.token.$_burn(this.holder, value)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, value, initialSupply - frozenAmount); + }); + }); + }); + + describe('approval functionality', function () { + const allowance = 40n; + + it('allows approval regardless of frozen or restricted status', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + await this.token.connect(this.freezer).setFrozen(this.holder, 0, 80n); + + await this.token.connect(this.holder).approve(this.approved, allowance); + await expect(this.token.allowance(this.holder, this.approved)).to.eventually.equal(allowance); + }); + + describe('transferFrom with combined restrictions', function () { + beforeEach(async function () { + await this.token.connect(this.holder).approve(this.approved, allowance); + }); + + it('allows transferFrom when all conditions are met', async function () { + await expect( + this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance), + ).to.changeTokenBalances(this.token, [this.holder, this.recipient], [-allowance, allowance]); + }); + + it('reverts transferFrom when sender is restricted', async function () { + await this.token.$_blockUser(this.holder); // Sets to BLOCKED + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.holder); + }); + + it('reverts transferFrom when recipient is restricted', async function () { + await this.token.$_blockUser(this.recipient); // Sets to BLOCKED + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20UserRestricted') + .withArgs(this.recipient); + }); + + it('reverts transferFrom when insufficient unfrozen balance', async function () { + const frozenAmount = 70n; // Available: 100 - 70 = 30, trying to transfer 40 + await this.token.connect(this.freezer).setFrozen(this.holder, 0, frozenAmount); + + await expect(this.token.connect(this.approved).transferFrom(this.holder, this.recipient, allowance)) + .to.be.revertedWithCustomError(this.token, 'ERC20InsufficientUnfrozenBalance') + .withArgs(this.holder, allowance, initialSupply - frozenAmount); + }); + }); + }); +});