Skip to content

Commit f5c0e31

Browse files
ernestognwjames-toussaintarr00
authored
Add ERC20Freezable, ERC20Restricted and ERC20uRWA (#186)
Co-authored-by: James Toussaint <[email protected]> Co-authored-by: Arr00 <[email protected]>
1 parent b0c00eb commit f5c0e31

File tree

11 files changed

+1182
-1
lines changed

11 files changed

+1182
-1
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
## 17-08-2025
2+
3+
- `ERC20Freezable`: Add extension of ERC-20 that allows freezing specific amounts of tokens per account, preventing transfers until unfrozen while maintaining full visibility of balances.
4+
- `ERC20Restricted`: Add extension of ERC-20 that implements user account transfer restrictions through allowlist/blocklist functionality based on ERC-7943.
5+
- `ERC20uRWA`: Add comprehensive ERC-20 extension implementing ERC-7943 specification for unified Real World Assets (uRWAs) with freezing, restrictions, and forced transfer capabilities.
6+
- `ERC20Custodian`: Deprecate in favor of `ERC20Freezable`.
7+
- `ERC20Allowlist`, `ERC20Blocklist`: Deprecate in favor of `ERC20Restricted`.
8+
19
## 14-08-2025
210

311
- `ZKEmailUtils`: Add `tryDecodeEmailProof` function for safe calldata decoding with comprehensive bounds checking and validation for `EmailProof` struct.
@@ -162,7 +170,7 @@
162170

163171
- `ERC20Allowlist`: Extension of ERC-20 that implements an allow list to enable token transfers, disabled by default.
164172
- `ERC20Blocklist`: Extension of ERC-20 that implements a block list to restrict token transfers, enabled by default.
165-
- `ERC20Custodian`: Extension of ERC-20 that allows a custodian to freeze user's tokens by a certain amount.
173+
- : Deprecate in favor of `ERC20Freezable`.: Extension of ERC-20 that allows a custodian to freeze user's tokens by a certain amount.
166174

167175
## 03-10-2024
168176

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.26;
3+
4+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
import {ERC20uRWA} from "../../token/ERC20/extensions/ERC20uRWA.sol";
6+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
7+
8+
abstract contract ERC20uRWAMock is ERC20uRWA, AccessControl {
9+
bytes32 public constant FREEZER_ROLE = keccak256("FREEZER_ROLE");
10+
bytes32 public constant ENFORCER_ROLE = keccak256("ENFORCER_ROLE");
11+
12+
constructor(address freezer, address enforcer) {
13+
_grantRole(FREEZER_ROLE, freezer);
14+
_grantRole(ENFORCER_ROLE, enforcer);
15+
}
16+
17+
function supportsInterface(
18+
bytes4 interfaceId
19+
) public view virtual override(ERC20uRWA, AccessControl) returns (bool) {
20+
return super.supportsInterface(interfaceId);
21+
}
22+
23+
function _checkEnforcer(address, address, uint256) internal view override onlyRole(ENFORCER_ROLE) {}
24+
25+
function _checkFreezer(address, uint256) internal view override onlyRole(FREEZER_ROLE) {}
26+
}

contracts/token/ERC20/extensions/ERC20Allowlist.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
1515
* on its behalf if {_allowUser} was not called with such account as an
1616
* argument. Similarly, the account will be disallowed again if
1717
* {_disallowUser} is called.
18+
*
19+
* IMPORTANT: Deprecated. Use {ERC20Restricted} instead.
1820
*/
1921
abstract contract ERC20Allowlist is ERC20 {
2022
/**

contracts/token/ERC20/extensions/ERC20Blocklist.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
1515
* on its behalf if {_blockUser} was not called with such account as an
1616
* argument. Similarly, the account will be unblocked again if
1717
* {_unblockUser} is called.
18+
*
19+
* IMPORTANT: Deprecated. Use {ERC20Restricted} instead.
1820
*/
1921
abstract contract ERC20Blocklist is ERC20 {
2022
/**

contracts/token/ERC20/extensions/ERC20Custodian.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
1616
* The frozen balance is not available for transfers or approvals
1717
* to other entities to operate on its behalf if. The frozen balance
1818
* can be reduced by calling {freeze} again with a lower amount.
19+
*
20+
* IMPORTANT: Deprecated. Use {ERC20Freezable} instead.
1921
*/
2022
abstract contract ERC20Custodian is ERC20 {
2123
/**
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.26;
4+
5+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
7+
import {IERC7943} from "../../../interfaces/IERC7943.sol";
8+
9+
/**
10+
* @dev Extension of {ERC20} that allows to implement a freezing
11+
* mechanism that can be managed by an authorized account with the
12+
* {_freezeTokens} and {_unfreezeTokens} functions.
13+
*
14+
* The freezing mechanism provides the guarantee to the contract owner
15+
* (e.g. a DAO or a well-configured multisig) that a specific amount
16+
* of tokens held by an account won't be transferable until those
17+
* tokens are unfrozen using {_unfreezeTokens}.
18+
*/
19+
abstract contract ERC20Freezable is ERC20 {
20+
/// @dev Frozen amount of tokens per address.
21+
mapping(address account => uint256) private _frozenBalances;
22+
23+
/// @dev The operation failed because the user has insufficient unfrozen balance.
24+
error ERC20InsufficientUnfrozenBalance(address user, uint256 needed, uint256 available);
25+
26+
/// @dev Returns the frozen balance of an account.
27+
function frozen(address account) public view virtual returns (uint256) {
28+
return _frozenBalances[account];
29+
}
30+
31+
/// @dev Returns the available (unfrozen) balance of an account. Up to {balanceOf}.
32+
function available(address account) public view virtual returns (uint256) {
33+
(bool success, uint256 unfrozen) = Math.trySub(balanceOf(account), _frozenBalances[account]);
34+
return success ? unfrozen : 0;
35+
}
36+
37+
/// @dev Internal function to set the frozen token amount for a user.
38+
function _setFrozen(address user, uint256 amount) internal virtual {
39+
_frozenBalances[user] = amount;
40+
emit IERC7943.Frozen(user, 0, amount);
41+
}
42+
43+
/**
44+
* @dev See {ERC20-_update}.
45+
*
46+
* Requirements:
47+
*
48+
* * `from` must have sufficient unfrozen balance.
49+
*/
50+
function _update(address from, address to, uint256 value) internal virtual override {
51+
if (from != address(0)) {
52+
uint256 unfrozen = available(from);
53+
require(unfrozen >= value, ERC20InsufficientUnfrozenBalance(from, value, unfrozen));
54+
}
55+
super._update(from, to, value);
56+
}
57+
58+
// We don't check frozen balance for approvals since the actual transfer
59+
// will be checked in _update. This allows for more flexible approval patterns.
60+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.26;
4+
5+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
7+
/**
8+
* @dev Extension of {ERC20} that allows to implement user account transfer restrictions
9+
* through the {isUserAllowed} function. Inspired by https://eips.ethereum.org/EIPS/eip-7943[EIP-7943].
10+
*
11+
* By default, each account has no explicit restriction. The {isUserAllowed} function acts as
12+
* a blocklist. Developers can override {isUserAllowed} to check that `restriction == ALLOWED`
13+
* to implement an allowlist.
14+
*/
15+
abstract contract ERC20Restricted is ERC20 {
16+
enum Restriction {
17+
DEFAULT, // User has no explicit restriction
18+
BLOCKED, // User is explicitly blocked
19+
ALLOWED // User is explicitly allowed
20+
}
21+
22+
mapping(address account => Restriction) private _restrictions;
23+
24+
/// @dev Emitted when a user account's restriction is updated.
25+
event UserRestrictionsUpdated(address indexed account, Restriction restriction);
26+
27+
/// @dev The operation failed because the user account is restricted.
28+
error ERC20UserRestricted(address account);
29+
30+
/// @dev Returns the restriction of a user account.
31+
function getRestriction(address account) public view virtual returns (Restriction) {
32+
return _restrictions[account];
33+
}
34+
35+
/**
36+
* @dev Returns whether a user account is allowed to interact with the token.
37+
*
38+
* Default implementation only disallows explicitly BLOCKED accounts (i.e. a blocklist).
39+
*
40+
* To convert into an allowlist, override as:
41+
*
42+
* ```solidity
43+
* function isUserAllowed(address account) public view virtual override returns (bool) {
44+
* return getRestriction(account) == Restriction.ALLOWED;
45+
* }
46+
* ```
47+
*/
48+
function isUserAllowed(address account) public view virtual returns (bool) {
49+
return getRestriction(account) != Restriction.BLOCKED; // i.e. DEFAULT && ALLOWED
50+
}
51+
52+
/**
53+
* @dev See {ERC20-_update}. Enforces restriction transfers (excluding minting and burning).
54+
*
55+
* Requirements:
56+
*
57+
* * `from` must be allowed to transfer tokens (see {isUserAllowed}).
58+
* * `to` must be allowed to receive tokens (see {isUserAllowed}).
59+
*/
60+
function _update(address from, address to, uint256 value) internal virtual override {
61+
if (from != address(0)) _checkRestriction(from); // Not minting
62+
if (to != address(0)) _checkRestriction(to); // Not burning
63+
super._update(from, to, value);
64+
}
65+
66+
// We don't check restrictions for approvals since the actual transfer
67+
// will be checked in _update. This allows for more flexible approval patterns.
68+
69+
/// @dev Updates the restriction of a user account.
70+
function _setRestriction(address account, Restriction restriction) internal virtual {
71+
if (getRestriction(account) != restriction) {
72+
_restrictions[account] = restriction;
73+
emit UserRestrictionsUpdated(account, restriction);
74+
} // no-op if restriction is unchanged
75+
}
76+
77+
/// @dev Convenience function to block a user account (set to BLOCKED).
78+
function _blockUser(address account) internal virtual {
79+
_setRestriction(account, Restriction.BLOCKED);
80+
}
81+
82+
/// @dev Convenience function to allow a user account (set to ALLOWED).
83+
function _allowUser(address account) internal virtual {
84+
_setRestriction(account, Restriction.ALLOWED);
85+
}
86+
87+
/// @dev Convenience function to reset a user account to default restriction.
88+
function _resetUser(address account) internal virtual {
89+
_setRestriction(account, Restriction.DEFAULT);
90+
}
91+
92+
/// @dev Checks if a user account is restricted. Reverts with {ERC20Restricted} if so.
93+
function _checkRestriction(address account) internal view virtual {
94+
require(isUserAllowed(account), ERC20UserRestricted(account));
95+
}
96+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.26;
3+
4+
import {IERC7943} from "../../../interfaces/IERC7943.sol";
5+
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
6+
import {ERC165, IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
7+
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
8+
import {ERC20Freezable} from "./ERC20Freezable.sol";
9+
import {ERC20Restricted} from "./ERC20Restricted.sol";
10+
11+
/**
12+
* @dev Extension of {ERC20} according to https://eips.ethereum.org/EIPS/eip-7943[EIP-7943].
13+
*
14+
* Combines standard ERC-20 functionality with RWA-specific features like user restrictions,
15+
* asset freezing, and forced asset transfers.
16+
*/
17+
abstract contract ERC20uRWA is ERC20, ERC165, ERC20Freezable, ERC20Restricted, IERC7943 {
18+
/// @inheritdoc ERC20Restricted
19+
function isUserAllowed(address user) public view virtual override(IERC7943, ERC20Restricted) returns (bool) {
20+
return super.isUserAllowed(user);
21+
}
22+
23+
/// @inheritdoc ERC165
24+
function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
25+
return interfaceId == type(IERC7943).interfaceId || super.supportsInterface(interfaceId);
26+
}
27+
28+
/**
29+
* @dev See {IERC7943-isTransferAllowed}.
30+
*
31+
* CAUTION: This function is only meant for external use. Overriding it will not apply the new checks to
32+
* the internal {_update} function. Consider overriding {_update} accordingly to keep both functions in sync.
33+
*/
34+
function isTransferAllowed(address from, address to, uint256, uint256 amount) external view virtual returns (bool) {
35+
return (amount <= available(from) && isUserAllowed(from) && isUserAllowed(to));
36+
}
37+
38+
/// @inheritdoc IERC7943
39+
function getFrozen(address user, uint256) public view virtual returns (uint256 amount) {
40+
return frozen(user);
41+
}
42+
43+
/**
44+
* @dev See {IERC7943-setFrozen}.
45+
*
46+
* NOTE: The `amount` is capped to the balance of the `user` to ensure the {IERC7943-Frozen} event
47+
* emits values that consistently reflect the actual amount of tokens that are frozen.
48+
*/
49+
function setFrozen(address user, uint256, uint256 amount) public virtual {
50+
uint256 actualAmount = Math.min(amount, balanceOf(user));
51+
_checkFreezer(user, actualAmount);
52+
_setFrozen(user, actualAmount);
53+
}
54+
55+
/**
56+
* @dev See {IERC7943-forceTransfer}.
57+
*
58+
* Bypasses the {ERC20Restricted} restrictions for the `from` address and adjusts the frozen balance
59+
* to the new balance after the transfer.
60+
*
61+
* NOTE: This function uses {_update} to perform the transfer, ensuring all standard ERC20
62+
* side effects (such as balance updates and events) are preserved. If you override {_update}
63+
* to add additional restrictions or logic, those changes will also apply here.
64+
* Consider overriding this function to bypass newer restrictions if needed.
65+
*/
66+
function forceTransfer(address from, address to, uint256, uint256 amount) public virtual {
67+
_checkEnforcer(from, to, amount);
68+
require(isUserAllowed(to), ERC7943NotAllowedUser(to));
69+
70+
// Update frozen balance if needed. ERC-7943 requires that balance is unfrozen first and then send the tokens.
71+
uint256 currentFrozen = frozen(from);
72+
uint256 newBalance;
73+
unchecked {
74+
// Safe because ERC20._update will check that balanceOf(from) >= amount
75+
newBalance = balanceOf(from) - amount;
76+
}
77+
if (currentFrozen > newBalance) {
78+
_setFrozen(from, newBalance);
79+
}
80+
81+
// Temporarily bypass restrictions rather than calling ERC20._update directly.
82+
// This preserves any side effects from future overrides to _update.
83+
// Assuming `forceTransfer` will be used occasionally, the added costs of temporary
84+
// restrictions would be justifiable under this path.
85+
Restriction restriction = getRestriction(from);
86+
bool wasUserAllowed = isUserAllowed(from);
87+
if (!wasUserAllowed) _setRestriction(from, Restriction.ALLOWED);
88+
_update(from, to, amount); // Explicit raw update to bypass all restrictions
89+
if (!wasUserAllowed) _setRestriction(from, restriction);
90+
emit ForcedTransfer(from, to, 0, amount);
91+
}
92+
93+
/// @inheritdoc ERC20
94+
function _update(
95+
address from,
96+
address to,
97+
uint256 amount
98+
) internal virtual override(ERC20, ERC20Freezable, ERC20Restricted) {
99+
// Note: We rely on the inherited _update chain (ERC20Freezable + ERC20Restricted) to enforce
100+
// the same restrictions that isTransferAllowed would check. This avoids duplicate validation
101+
// while maintaining consistency between external queries and internal transfer logic.
102+
super._update(from, to, amount);
103+
}
104+
105+
/**
106+
* @dev Internal function to check if the `enforcer` is allowed to forcibly transfer the `amount` of `tokens`.
107+
*
108+
* Example usage with {AccessControl-onlyRole}:
109+
*
110+
* ```solidity
111+
* function _checkEnforcer(address from, address to, uint256 amount) internal view override onlyRole(ENFORCER_ROLE) {}
112+
* ```
113+
*/
114+
function _checkEnforcer(address from, address to, uint256 amount) internal view virtual;
115+
116+
/**
117+
* @dev Internal function to check if the `freezer` is allowed to freeze the `amount` of `tokens`.
118+
*
119+
* Example usage with {AccessControl-onlyRole}:
120+
*
121+
* ```solidity
122+
* function _checkFreezer(address user, uint256 amount) internal view override onlyRole(FREEZER_ROLE) {}
123+
* ```
124+
*/
125+
function _checkFreezer(address user, uint256 amount) internal view virtual;
126+
}

0 commit comments

Comments
 (0)