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 25 commits into
base: master
Choose a base branch
from

Conversation

ernestognw
Copy link
Member

@ernestognw ernestognw commented Jul 10, 2025

ERC-7943 made it as a draft, so I think it's fine to start by implementing the ERC20 version. A couple of notes:

  • We already have an ERC20Allowlist and ERC20Blocklist that is offered in Wizard. I feel a better abstraction would be ERC20Restricted, otherwise the ERC20uRWA implementation would keep isUserAllowed virtual to return allowed or blocked accordingly
  • I'm not restricting the approve function but we're doing it in ERC20Allowlist and ERC20Blocklist so we need to make sure there's not an alternative path to bypass the restrictions

@ernestognw ernestognw changed the title Add ERC20Freezable and ERC20uRWA Add ERC20Freezable, ERC20Restricted and ERC20uRWA Jul 11, 2025
@ernestognw ernestognw marked this pull request as ready for review July 11, 2025 18:59
@ernestognw ernestognw requested a review from a team as a code owner July 11, 2025 18:59
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

* }
* ```
*/
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

* ```
*/
function isUserAllowed(address user) public view virtual returns (bool) {
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)

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

// solhint-disable-next-line contract-name-capwords
abstract contract uRWA20Mock is uRWA20, AccessControl {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
abstract contract uRWA20Mock is uRWA20, AccessControl {
contract uRWA20Mock is uRWA20, AccessControl {

?
Do we need the mock to be abstract?

Copy link
Member Author

@ernestognw ernestognw Jul 22, 2025

Choose a reason for hiding this comment

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

It's not needed but it helps because hardhat-exposed exposes the constructors automatically 😄

EDIT: See cf08cbf

Copy link
Collaborator

@arr00 arr00 Jul 29, 2025

Choose a reason for hiding this comment

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

wRWA will always break linting
image

Copy link
Collaborator

Choose a reason for hiding this comment

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

Can the file name match the contract?

Comment on lines +58 to +59
// We don't check frozen balance for approvals since the actual transfer
// will be checked in _update. This allows for more flexible approval patterns.
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

@ernestognw ernestognw requested review from james-toussaint and a team July 29, 2025 17:02
@ernestognw ernestognw requested a review from arr00 July 31, 2025 17:00
Comment on lines 75 to 77
} 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants