Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
40 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
bd8c957
up
ernestognw Aug 15, 2025
4b33af9
up
ernestognw Aug 15, 2025
e7e17f3
Update forceTransfer
ernestognw Aug 15, 2025
f9b9dc3
Merge branch 'master' into feature/erc-7943
ernestognw Aug 15, 2025
cc77616
Add CHANGELOG entries
ernestognw Aug 15, 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
83 changes: 83 additions & 0 deletions contracts/interfaces/IERC7943.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";

/// @notice Defines the ERC-7943 interface, the uRWA.
/// When interacting with specific token standards:
/// - For ERC-721 like (non-fungible) tokens 'amount' parameters typically represent a single token (i.e., 1).
/// - For ERC-20 like (fungible) tokens, 'tokenId' parameters are generally not applicable and should be set to 0.
interface IERC7943 is IERC165 {
/// @notice Emitted when tokens are taken from one address and transferred to another.
/// @param from The address from which tokens were taken.
/// @param to The address to which seized tokens were transferred.
/// @param tokenId The ID of the token being transferred.
/// @param amount The amount seized.
event ForcedTransfer(address indexed from, address indexed to, uint256 tokenId, uint256 amount);

/// @notice Emitted when `setFrozen` is called, changing the frozen `amount` of `tokenId` tokens for `user`.
/// @param user The address of the user whose tokens are being frozen.
/// @param tokenId The ID of the token being frozen.
/// @param amount The amount of tokens frozen after the change.
event Frozen(address indexed user, uint256 indexed tokenId, uint256 amount);

/// @notice Error reverted when a user is not allowed to interact.
/// @param account The address of the user which is not allowed for interactions.
error ERC7943NotAllowedUser(address account);

/// @notice Error reverted when a transfer is not allowed due to restrictions in place.
/// @param from The address from which tokens are being transferred.
/// @param to The address to which tokens are being transferred.
/// @param tokenId The ID of the token being transferred.
/// @param amount The amount being transferred.
error ERC7943NotAllowedTransfer(address from, address to, uint256 tokenId, uint256 amount);

/// @notice Error reverted when a transfer is attempted from `user` with an `amount` of `tokenId` less or equal than its balance, but greater than its unfrozen balance.
/// @param user The address holding the tokens.
/// @param tokenId The ID of the token being transferred.
/// @param amount The amount being transferred.
/// @param unfrozen The amount of tokens that are unfrozen and available to transfer.
error ERC7943InsufficientUnfrozenBalance(address user, uint256 tokenId, uint256 amount, uint256 unfrozen);

/// @notice Takes tokens from one address and transfers them to another.
/// @dev Requires specific authorization. Used for regulatory compliance or recovery scenarios.
/// @param from The address from which `amount` is taken.
/// @param to The address that receives `amount`.
/// @param tokenId The ID of the token being transferred.
/// @param amount The amount to force transfer.
function forceTransfer(address from, address to, uint256 tokenId, uint256 amount) external;

/// @notice Changes the frozen status of `amount` of `tokenId` tokens belonging to an `user`.
/// This overwrites the current value, similar to an `approve` function.
/// @dev Requires specific authorization. Frozen tokens cannot be transferred by the user.
/// @param user The address of the user whose tokens are to be frozen/unfrozen.
/// @param tokenId The ID of the token to freeze/unfreeze.
/// @param amount The amount of tokens to freeze/unfreeze.
function setFrozen(address user, uint256 tokenId, uint256 amount) external;

/// @notice Checks the frozen status/amount of a specific `tokenId`.
/// @param user The address of the user.
/// @param tokenId The ID of the token.
/// @return amount The amount of `tokenId` tokens currently frozen for `user`.
function getFrozen(address user, uint256 tokenId) external view returns (uint256 amount);

/// @notice Checks if a transfer is currently possible according to token rules. It enforces validations on the frozen tokens.
/// @dev This may involve checks like allowlists, blocklists, transfer limits and other policy-defined restrictions.
/// @param from The address sending tokens.
/// @param to The address receiving tokens.
/// @param tokenId The ID of the token being transferred.
/// @param amount The amount being transferred.
/// @return allowed True if the transfer is allowed, false otherwise.
function isTransferAllowed(
address from,
address to,
uint256 tokenId,
uint256 amount
) external view returns (bool allowed);

/// @notice Checks if a specific user is allowed to interact according to token rules.
/// @dev This is often used for allowlist/KYC/KYB/AML checks.
/// @param user The address to check.
/// @return allowed True if the user is allowed, false otherwise.
function isUserAllowed(address user) external view returns (bool allowed);
}
58 changes: 58 additions & 0 deletions contracts/token/ERC20/extensions/ERC20Freezable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.26;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.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) {
return balanceOf(account) - _frozenBalances[account];
}

/// @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.20;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
* @dev Extension of {ERC20} that allows to implement user access restrictions
* through the {isUserAllowed} function. Inspired by https://eips.ethereum.org/EIPS/eip-7943[EIP-7943].
*
* By default, each user has no explicit restriction. The {isUserAllowed} function acts as
* an allowlist. Developers can override {isUserAllowed} to check that `restriction != RESTRICTED`
* to implement a blocklist.
*/
abstract contract ERC20Restricted is ERC20 {
enum Restriction {
DEFAULT, // User has no explicit restriction
RESTRICTED, // User is explicitly restricted
UNRESTRICTED // User is explicitly unrestricted
}

mapping(address user => Restriction) private _restrictions;

/// @dev Emitted when a user's restriction is updated.
event UserRestrictionsUpdated(address indexed user, Restriction restriction);

/// @dev The operation failed because the user is restricted.
error ERC20Restricted(address user);

/// @dev Returns the restriction of an account.
function getRestriction(address user) public view virtual returns (Restriction) {
return _restrictions[user];
}

/**
* @dev Returns whether a user is allowed to interact with the token.
*
* Default implementation only disallows explicitly RESTRICTED users (i.e. a blocklist).
*
* To convert into an allowlist, override as:
*
* ```solidity
* function isUserAllowed(address user) public view virtual override returns (bool) {
* return getRestriction(user) != Restriction.UNRESTRICTED; // i.e. DEFAULT && UNRESTRICTED
* }
* ```
*/
function isUserAllowed(address user) public view virtual returns (bool) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Choosing a default looks opinionated, how about leaving that abstract?

Maybe I misunderstood you initial comment here:

otherwise the ERC20uRWA implementation would keep isUserAllowed virtual to return allowed or blocked accordingly

Copy link
Member Author

Choose a reason for hiding this comment

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

Ah, that comment is related to how the ERC20uRWA was set up in the first place. See this comment.

I agree that a default is opinionated, but I think we don want to be opinionated here to avoid users to make a decision at first. I prefer a non-restrictive default rather than a virtual function

return getRestriction(user) != Restriction.RESTRICTED; // i.e. DEFAULT && UNRESTRICTED
Copy link
Collaborator

Choose a reason for hiding this comment

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

How about adding the two isAllowed(address user) & isNotBlocked(address user) so user can straightfowardly override:

function isUserAllowed(address user) public view override returns (bool) {
  return isNotBlocked(user); // or !isBlocked(..)
}
function isUserAllowed(address user) public view override returns (bool) {
  return isAllowed(user);
}

(?)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, good idea. Though, these functions come in ERC20Allowlist and ERC20Blocklist (i.e. allowed and blocked respectively). Initially ERC20Restricted was not a thing and this was the default recommendation (see here):

// Using ERC20Allowlist
contract MyuRWA20 is uRWA20, ERC20Allowlist {
    function isUserAllowed(address user) public view virtual override returns (bool) {
        return allowed(user);
    }
}

// Using ERC20Blocklist
contract MyuRWA20 is uRWA20, ERC20Blocklist {
    function isUserAllowed(address user) public view virtual override returns (bool) {
        return !blocked(user);
    }
}

I currently prefer the ERC20Freezable with a default "blocklist" behavior, since I believe is the least restrictive option (i.e. an allowlist is just a stricter blocklist)

}

/**
* @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)) _checkRestricted(from); // Minting
if (to != address(0)) _checkRestricted(to); // 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.
function _setRestriction(address user, Restriction restriction) internal virtual {
if (getRestriction(user) != restriction) {
_restrictions[user] = restriction;
emit UserRestrictionsUpdated(user, restriction);
} // no-op if restriction is unchanged
}

/// @dev Convenience function to restrict a user (set to RESTRICTED).
function _allowUser(address user) internal virtual {
_setRestriction(user, Restriction.RESTRICTED);
}

/// @dev Convenience function to disallow a user (set to UNRESTRICTED).
function _disallowUser(address user) internal virtual {
_setRestriction(user, Restriction.UNRESTRICTED);
}

/// @dev Convenience function to reset a user to default restriction.
function _resetUser(address user) internal virtual {
_setRestriction(user, Restriction.DEFAULT);
}

/// @dev Checks if a user is restricted. Reverts with {ERC20Restricted} if so.
function _checkRestricted(address user) internal view virtual {
require(isUserAllowed(user), ERC20Restricted(user));
}
}
104 changes: 104 additions & 0 deletions contracts/token/ERC20/extensions/ERC20uRWA.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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 {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.
*/
// solhint-disable-next-line contract-name-capwords
abstract contract uRWA20 is ERC20, ERC20Freezable, ERC20Restricted, IERC7943, ERC165 {
/// @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);
}

/// @inheritdoc IERC7943
function isTransferAllowed(address from, address to, uint256, uint256 amount) public 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);
}

/// @inheritdoc IERC7943
function setFrozen(address user, uint256, uint256 amount) public virtual {
require(amount <= balanceOf(user), ERC20InsufficientBalance(user, balanceOf(user), amount));
_checkFreezer(user, amount);
_setFrozen(user, amount);
}

/// @inheritdoc IERC7943
function forceTransfer(address from, address to, uint256, uint256 amount) public virtual {
require(isUserAllowed(to), ERC7943NotAllowedUser(to));
_checkEnforcer(from, to, amount);

// Update frozen balance if needed
uint256 currentFrozen = frozen(from);
uint256 currentBalance = balanceOf(from);
if (currentFrozen > currentBalance - amount) {
_setFrozen(from, currentBalance - amount);
}

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added a CAUTION note as we agreed in our 1:1

Copy link
Member Author

Choose a reason for hiding this comment

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

Actually I updated the forceTransfer function to remove the restrictions from storage momentarily before calling _update. I think it's more important to preserve side effects down the inheritance chain than saving gas in that function. Also, calling forceTransfer should be really occasional, so it's justifiable imo

emit ForcedTransfer(from, to, 0, amount);
}

/**
* @dev See {ERC20-_update}.
*
* Requirements:
*
* * `from` and `to` must be allowed to transfer `amount` tokens (see {isTransferAllowed}).
*/
function _update(
address from,
address to,
uint256 amount
) internal virtual override(ERC20, ERC20Freezable, ERC20Restricted) {
if (from == address(0)) {
// Minting
require(isUserAllowed(to), ERC7943NotAllowedUser(to));
} else if (to != address(0)) {
// Transfer
require(isTransferAllowed(from, to, 0, amount), ERC7943NotAllowedTransfer(from, to, 0, amount));
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 think I'll remove this check. Essentially, it duplicates the check that super._update already does given it inherits from ERC20Freezable.

In terms of compliance, the spec states:

Public transfers (transfer, transferFrom, safeTransferFrom, etc.) MUST NOT succeed in cases in which isTransferAllowed or isUserAllowed would return false for either one or both from and to addresses.

So this is still true even if we don't call isTransferAllowed here.

The isTransferAllowed MUST validate that the amount being transferred doesn't exceed the unfrozen amount (which is the difference between the current balance and the frozen balance). Additionally it MUST perform an isUserAllowed check on the from and to parameters.

  1. ERC20Freezable._update checks:

    • if (from != address(0)) → validates amount <= available(from)
  2. ERC20Restricted._update checks:

    • if (from != address(0)) _checkRestricted(from) → calls isUserAllowed(from)
    • if (to != address(0)) _checkRestricted(to) → calls isUserAllowed(to)

This change would stop emitting the ERC7943NotAllowedTransfer error, but I think that's fine since it's not very expressive and the super._update call will revert with more appropriate errors. The ERC allows to skip reverting with ERC7943NotAllowedTransfer.

As a side effect, I think any override to isTransferAllowed would not be enforced in _update automatically, so I guess we can note it.

}
super._update(from, to, amount);
}

/**
* @dev Internal function to check if the enforcer is allowed to force transfer.
*
* 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 user has sufficient unfrozen balance.
*
* 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