Skip to content

Add ERC20Freezable, ERC20Restricted and ERC20uRWA #186

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 35 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2072d11
Add ERC20Freezable and ERC20uRWA
ernestognw Jul 10, 2025
f8dac42
up
ernestognw Jul 11, 2025
25c4e9e
up
ernestognw Jul 11, 2025
2b9b080
up
ernestognw Jul 11, 2025
a9f4f7b
up
ernestognw Jul 11, 2025
f874ce4
Update contracts/token/ERC20/extensions/ERC20Restricted.sol
ernestognw Jul 11, 2025
bffb8db
up
ernestognw Jul 11, 2025
85f14e1
up
ernestognw Jul 11, 2025
7629289
Merge branch 'master' into feature/erc-7943
ernestognw Jul 11, 2025
9f4c5d7
Add tests
ernestognw Jul 11, 2025
469daac
Update contracts/token/ERC20/extensions/ERC20Restricted.sol
ernestognw Jul 12, 2025
cf08cbf
up
ernestognw Jul 25, 2025
e29a289
Deprecate ERC20Allowlist, ERC20Blocklist, ERC20Custodian
ernestognw Jul 25, 2025
8f81436
Update contracts/token/ERC20/extensions/ERC20uRWA.sol
ernestognw Jul 25, 2025
382a4c6
Review comments
ernestognw Jul 25, 2025
ae6b960
Review comment
ernestognw Jul 25, 2025
ee40dcf
More review comments
ernestognw Jul 25, 2025
237d84f
Fix tests
ernestognw Jul 25, 2025
509bcf7
Update contracts/token/ERC20/extensions/ERC20Restricted.sol
ernestognw Jul 31, 2025
2885586
RESTRICTED/UNRESTRICTED -> ALLOWED/BLOCKED
ernestognw Jul 31, 2025
a75dd13
uRWA20 to ERC20uRWA
ernestognw Jul 31, 2025
2b08724
Merge branch 'master' into feature/erc-7943
ernestognw Jul 31, 2025
d3750e5
remove zk-jwt
ernestognw Jul 31, 2025
ec5a726
Remove solhint annotation
ernestognw Jul 31, 2025
c1e16f0
Remove duplicated check in _update
ernestognw Jul 31, 2025
33a017b
Review suggestions
ernestognw Aug 6, 2025
73c78aa
Review
ernestognw Aug 7, 2025
c1cd6b5
Clarify comment
ernestognw Aug 7, 2025
678682f
Simplify ZKEmailUtils
ernestognw Aug 7, 2025
ed18ff1
Revert "Simplify ZKEmailUtils"
ernestognw Aug 7, 2025
ff6b169
up
ernestognw Aug 7, 2025
902f32a
Review
ernestognw Aug 7, 2025
f5b2008
up
ernestognw Aug 7, 2025
bc86ed6
up
ernestognw Aug 7, 2025
e65db0d
up
ernestognw Aug 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions contracts/mocks/token/ERC20uRWAMock.sol
Original file line number Diff line number Diff line change
@@ -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) {}
}
2 changes: 2 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Allowlist.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
2 changes: 2 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Blocklist.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
2 changes: 2 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Custodian.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
60 changes: 60 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Freezable.sol
Original file line number Diff line number Diff line change
@@ -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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about ERC7943InsufficientUnfrozenBalance ? Is not mandatory but just curious of your thoughts

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I felt it was not worth introducing concepts outside of ERC20Freezable's domain. So I preferred to keep ERC20InsufficientUnfrozenBalance for consistency with the contract naming. None is objectively better imo, though.

btw I just recalled that we already have an ERC20Custodian that might replace this ERC20Freezable, I just need to take a deeper look


/// @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.
Comment on lines +58 to +59
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm almost sure there was a reason why we were restricting approvals in ERC20Allowlist and ERC20Blocklist but can't remember it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, it is not possible to bypass the restriction since every transferFrom would go through _update

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree with not checking

}
96 changes: 96 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Restricted.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}
113 changes: 113 additions & 0 deletions contracts/token/ERC20/extensions/ERC20uRWA.sol
Original file line number Diff line number Diff line change
@@ -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));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this result in other effects being skipped? Voting power transfer for example.

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;
}
Loading