-
Notifications
You must be signed in to change notification settings - Fork 23
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
Changes from 4 commits
2072d11
f8dac42
25c4e9e
2b9b080
a9f4f7b
f874ce4
bffb8db
85f14e1
7629289
9f4c5d7
469daac
cf08cbf
e29a289
8f81436
382a4c6
ae6b960
ee40dcf
237d84f
509bcf7
2885586
a75dd13
2b08724
d3750e5
ec5a726
c1e16f0
33a017b
73c78aa
c1cd6b5
678682f
ed18ff1
ff6b169
902f32a
f5b2008
bc86ed6
e65db0d
bd8c957
4b33af9
e7e17f3
f9b9dc3
cc77616
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
} |
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); | ||
|
||
/// @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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For clarity, it is not possible to bypass the restriction since every There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agree with not checking |
||
} |
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` | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* to implement a blocklist. | ||
*/ | ||
abstract contract ERC20Restricted is ERC20 { | ||
enum Restriction { | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* } | ||
* ``` | ||
*/ | ||
function isUserAllowed(address user) public view virtual returns (bool) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about adding the two
(?) There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. // 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 { | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_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)); | ||
} | ||
} |
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)); | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_checkFreezer(user, amount); | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a CAUTION note as we agreed in our 1:1 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I updated the |
||
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 In terms of compliance, the spec states:
So this is still true even if we don't call
This change would stop emitting the ERC7943NotAllowedTransfer error, but I think that's fine since it's not very expressive and the As a side effect, I think any override to |
||
} | ||
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. | ||
ernestognw marked this conversation as resolved.
Show resolved
Hide resolved
|
||
* | ||
* 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; | ||
} |
There was a problem hiding this comment.
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 thoughtsThere was a problem hiding this comment.
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