From 694bbc3a1a56e7aa2b622fd0a20b7f18d430c46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernani=20S=C3=A3o=20Thiago?= Date: Mon, 22 Apr 2024 09:47:04 -0600 Subject: [PATCH] ON-811: ERC-7904 Wrapper (#28) ON-811: implemented ERC7432WrapperForERC4907 --- .openzeppelin/unknown-11155111.json | 137 +++++ .openzeppelin/unknown-1284.json | 132 +++++ addresses/index.ts | 11 + addresses/moonbeam/index.json | 8 + addresses/sepoliaTestnet/index.json | 8 + .../ERC7432/ERC7432WrapperForERC4907.sol | 253 ++++++++ contracts/ERC7432/NftRolesRegistryVault.sol | 4 + contracts/OriumWrapperManager.sol | 65 ++ .../interfaces/DoubleProtocol/IWrapNFT.sol | 19 + contracts/interfaces/IERC4907.sol | 29 + .../interfaces/IERC7432VaultExtension.sol | 10 +- contracts/interfaces/IOriumWrapperManager.sol | 47 ++ hardhat.config.ts | 31 +- .../04-deploy-erc7432-wrapper-for-erc4907.ts | 72 +++ .../05-wrapper-manager-functions.ts | 57 ++ test/ERC7432/ERC7432WrapperForERC4907.spec.ts | 557 ++++++++++++++++++ test/ERC7432/NftRolesRegistryVault.ts | 12 +- test/ERC7432/mockData.ts | 2 +- test/OriumWrapperManager.spec.ts | 70 +++ test/helpers.ts | 1 + utils/json.ts | 13 + utils/misc.ts | 95 +++ 22 files changed, 1612 insertions(+), 21 deletions(-) create mode 100644 .openzeppelin/unknown-11155111.json create mode 100644 .openzeppelin/unknown-1284.json create mode 100644 addresses/index.ts create mode 100644 addresses/moonbeam/index.json create mode 100644 addresses/sepoliaTestnet/index.json create mode 100644 contracts/ERC7432/ERC7432WrapperForERC4907.sol create mode 100644 contracts/OriumWrapperManager.sol create mode 100644 contracts/interfaces/DoubleProtocol/IWrapNFT.sol create mode 100644 contracts/interfaces/IERC4907.sol create mode 100644 contracts/interfaces/IOriumWrapperManager.sol create mode 100644 scripts/roles-registry/04-deploy-erc7432-wrapper-for-erc4907.ts create mode 100644 scripts/roles-registry/05-wrapper-manager-functions.ts create mode 100644 test/ERC7432/ERC7432WrapperForERC4907.spec.ts create mode 100644 test/OriumWrapperManager.spec.ts create mode 100644 utils/json.ts create mode 100644 utils/misc.ts diff --git a/.openzeppelin/unknown-11155111.json b/.openzeppelin/unknown-11155111.json new file mode 100644 index 0000000..3dba9c0 --- /dev/null +++ b/.openzeppelin/unknown-11155111.json @@ -0,0 +1,137 @@ +{ + "manifestVersion": "3.2", + "admin": { + "address": "0xE830038A395CE7B3E4A1714e2c21eF70915741C5", + "txHash": "0x57627f959312cb51684f22b9b82cf75b6a2add230d5e668b72eb8d9f15cc3cb4" + }, + "proxies": [ + { + "address": "0x6F54401fA5f002F42749eA3D1E34FCDDB4A8b852", + "txHash": "0xf74a97d22f24321020bce6889b4bb4101f123b942840f451b1343240f08c0386", + "kind": "transparent" + }, + { + "address": "0x6E7217d70ef133284dc77C30e29cE1c0A739F8f0", + "txHash": "0x6dd92b51cb993cfbf9277a49bfc6ddf7c5a473f862611b2547d0d2e046a395bf", + "kind": "transparent" + } + ], + "impls": { + "238f836ab9403f8ca72ff26027bcc80f0a8181601fb6f5de5fa9950dced3bc7a": { + "address": "0x2F881C5c65092ebe28C31b52Ce76A8158A720ADf", + "txHash": "0x99ebc051501123cdb3f0dd565c29c40bf6d0d9d10c6534722db1bace386b879d", + "layout": { + "solcVersion": "0.8.9", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "marketplaceAddress", + "offset": 0, + "slot": "101", + "type": "t_address", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:10" + }, + { + "label": "wrappedTokenOf", + "offset": 0, + "slot": "102", + "type": "t_mapping(t_address,t_address)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:13" + }, + { + "label": "originalTokenOf", + "offset": 0, + "slot": "103", + "type": "t_mapping(t_address,t_address)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:16" + }, + { + "label": "maxDurationOf", + "offset": 0, + "slot": "104", + "type": "t_mapping(t_address,t_uint256)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:19" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_address)": { + "label": "mapping(address => address)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } + } + } +} diff --git a/.openzeppelin/unknown-1284.json b/.openzeppelin/unknown-1284.json new file mode 100644 index 0000000..6e77635 --- /dev/null +++ b/.openzeppelin/unknown-1284.json @@ -0,0 +1,132 @@ +{ + "manifestVersion": "3.2", + "admin": { + "address": "0x55DaC2D38817686fb5e6Dbd4393f2dAFB2e298F5", + "txHash": "0xd8a04266e1ebb69ebb462c1ef7b68f6f22eb5c8c50284629b7f3831681a77cfd" + }, + "proxies": [ + { + "address": "0x5E053177c73636d4378cfB4D095cFb374eBb3Da6", + "txHash": "0xbf87b3bfa21a8d002daf48ef4a85c0f6120b380ca5b51102ac083ecc9fff32dc", + "kind": "transparent" + } + ], + "impls": { + "238f836ab9403f8ca72ff26027bcc80f0a8181601fb6f5de5fa9950dced3bc7a": { + "address": "0x75EAcd67D71Aba6039bcC863E14dCe1f5263D93C", + "txHash": "0x1ce7481440e5d5055922e5b8497f629a8bc81bfd3f6efe66a75aaf3957f7f505", + "layout": { + "solcVersion": "0.8.9", + "storage": [ + { + "label": "_initialized", + "offset": 0, + "slot": "0", + "type": "t_uint8", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:62", + "retypedFrom": "bool" + }, + { + "label": "_initializing", + "offset": 1, + "slot": "0", + "type": "t_bool", + "contract": "Initializable", + "src": "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol:67" + }, + { + "label": "__gap", + "offset": 0, + "slot": "1", + "type": "t_array(t_uint256)50_storage", + "contract": "ContextUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/utils/ContextUpgradeable.sol:36" + }, + { + "label": "_owner", + "offset": 0, + "slot": "51", + "type": "t_address", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:22" + }, + { + "label": "__gap", + "offset": 0, + "slot": "52", + "type": "t_array(t_uint256)49_storage", + "contract": "OwnableUpgradeable", + "src": "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol:94" + }, + { + "label": "marketplaceAddress", + "offset": 0, + "slot": "101", + "type": "t_address", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:10" + }, + { + "label": "wrappedTokenOf", + "offset": 0, + "slot": "102", + "type": "t_mapping(t_address,t_address)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:13" + }, + { + "label": "originalTokenOf", + "offset": 0, + "slot": "103", + "type": "t_mapping(t_address,t_address)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:16" + }, + { + "label": "maxDurationOf", + "offset": 0, + "slot": "104", + "type": "t_mapping(t_address,t_uint256)", + "contract": "OriumWrapperManager", + "src": "contracts/OriumWrapperManager.sol:19" + } + ], + "types": { + "t_address": { + "label": "address", + "numberOfBytes": "20" + }, + "t_array(t_uint256)49_storage": { + "label": "uint256[49]", + "numberOfBytes": "1568" + }, + "t_array(t_uint256)50_storage": { + "label": "uint256[50]", + "numberOfBytes": "1600" + }, + "t_bool": { + "label": "bool", + "numberOfBytes": "1" + }, + "t_mapping(t_address,t_address)": { + "label": "mapping(address => address)", + "numberOfBytes": "32" + }, + "t_mapping(t_address,t_uint256)": { + "label": "mapping(address => uint256)", + "numberOfBytes": "32" + }, + "t_uint256": { + "label": "uint256", + "numberOfBytes": "32" + }, + "t_uint8": { + "label": "uint8", + "numberOfBytes": "1" + } + } + } + } + } +} diff --git a/addresses/index.ts b/addresses/index.ts new file mode 100644 index 0000000..a975515 --- /dev/null +++ b/addresses/index.ts @@ -0,0 +1,11 @@ +import sepoliaTestnet from './sepoliaTestnet/index.json' +import moonbeam from './moonbeam/index.json' + +const config = { + sepoliaTestnet, + moonbeam, +} + +export default config + +export type Network = keyof typeof config diff --git a/addresses/moonbeam/index.json b/addresses/moonbeam/index.json new file mode 100644 index 0000000..935819d --- /dev/null +++ b/addresses/moonbeam/index.json @@ -0,0 +1,8 @@ +{ + "OriumWrapperManager": { + "address": "0x5E053177c73636d4378cfB4D095cFb374eBb3Da6", + "operator": "0x04c8c6c56dab836f8bd62cb6884371507e706806", + "implementation": "0x75EAcd67D71Aba6039bcC863E14dCe1f5263D93C", + "proxyAdmin": "0x55DaC2D38817686fb5e6Dbd4393f2dAFB2e298F5" + } +} diff --git a/addresses/sepoliaTestnet/index.json b/addresses/sepoliaTestnet/index.json new file mode 100644 index 0000000..9645d8e --- /dev/null +++ b/addresses/sepoliaTestnet/index.json @@ -0,0 +1,8 @@ +{ + "OriumWrapperManager": { + "address": "0x6F54401fA5f002F42749eA3D1E34FCDDB4A8b852", + "operator": "0xf954Ad2d7B27aC20345631fbB776Ee423E7873b2", + "implementation": "0x2F881C5c65092ebe28C31b52Ce76A8158A720ADf", + "proxyAdmin": "0xE830038A395CE7B3E4A1714e2c21eF70915741C5" + } +} diff --git a/contracts/ERC7432/ERC7432WrapperForERC4907.sol b/contracts/ERC7432/ERC7432WrapperForERC4907.sol new file mode 100644 index 0000000..37d72c4 --- /dev/null +++ b/contracts/ERC7432/ERC7432WrapperForERC4907.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { IERC721Receiver } from '@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol'; +import { ERC721Holder } from '@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol'; +import { IERC7432 } from '../interfaces/IERC7432.sol'; +import { IERC4907 } from '../interfaces/IERC4907.sol'; +import { IERC7432VaultExtension } from '../interfaces/IERC7432VaultExtension.sol'; +import { IOriumWrapperManager } from '../interfaces/IOriumWrapperManager.sol'; +import { IWrapNFT } from '../interfaces/DoubleProtocol/IWrapNFT.sol'; + +/// @title ERC-7432 Wrapper for ERC-4907 +/// @dev This contract introduces a ERC-7432 interface to manage the role of ERC-4907 NFTs. +contract ERC7432WrapperForERC4907 is IERC7432, IERC7432VaultExtension, ERC721Holder { + bytes32 public constant USER_ROLE = keccak256('User()'); + + address public oriumWrapperManager; + + // tokenAddress => tokenId => owner + mapping(address => mapping(uint256 => address)) public originalOwners; + + // tokenAddress => tokenId => revocable + mapping(address => mapping(uint256 => bool)) public isRevocableRole; + + // owner => tokenAddress => operator => isApproved + mapping(address => mapping(address => mapping(address => bool))) public tokenApprovals; + + /** ######### Modifiers ########### **/ + + modifier onlyUserRole(bytes32 _roleId) { + require(_roleId == USER_ROLE, "ERC7432WrapperForERC4907: only 'User()' role is allowed"); + _; + } + + /** ERC-7432 External Functions **/ + + constructor(address _oriumWrapperManagerAddress) { + oriumWrapperManager = _oriumWrapperManagerAddress; + } + + function grantRole(Role calldata _role) external override onlyUserRole(_role.roleId) { + address _wrappedTokenAddress = IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_role.tokenAddress); + require(_wrappedTokenAddress != address(0), 'ERC7432WrapperForERC4907: token not supported'); + + require( + _role.expirationDate > block.timestamp && + _role.expirationDate < block.timestamp + IOriumWrapperManager(oriumWrapperManager).getMaxDurationOf(_role.tokenAddress), + 'ERC7432WrapperForERC4907: invalid expiration date' + ); + + // deposit NFT if necessary + // reverts if sender is not approved or original owner + address _originalOwner = _depositNft(_role.tokenAddress, _role.tokenId, _wrappedTokenAddress); + + // role must be expired or revocable + require( + isRevocableRole[_role.tokenAddress][_role.tokenId] || + IERC4907(_wrappedTokenAddress).userExpires(_role.tokenId) < block.timestamp, + 'ERC7432WrapperForERC4907: role must be expired or revocable' + ); + + IERC4907(_wrappedTokenAddress).setUser(_role.tokenId, _role.recipient, _role.expirationDate); + isRevocableRole[_role.tokenAddress][_role.tokenId] = _role.revocable; + emit RoleGranted( + _role.tokenAddress, + _role.tokenId, + _role.roleId, + _originalOwner, + _role.recipient, + _role.expirationDate, + _role.revocable, + _role.data + ); + } + + function revokeRole( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external override onlyUserRole(_roleId) { + address _wrappedTokenAddress = IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_tokenAddress); + require(_wrappedTokenAddress != address(0), 'ERC7432WrapperForERC4907: token not supported'); + + address _recipient = IERC4907(_wrappedTokenAddress).userOf(_tokenId); + address _caller = _getApprovedCaller(_tokenAddress, _tokenId, _recipient); + + // if caller is recipient, the role can be revoked regardless of its state + if (_caller != _recipient) { + // if caller is owner, the role can only be revoked if revocable or expired + require( + isRevocableRole[_tokenAddress][_tokenId] || + IERC4907(_wrappedTokenAddress).userExpires(_tokenId) < block.timestamp, + 'ERC7432WrapperForERC4907: role is not revocable nor expired' + ); + } + + delete isRevocableRole[_tokenAddress][_tokenId]; + IERC4907(_wrappedTokenAddress).setUser(_tokenId, address(0), uint64(0)); + emit RoleRevoked(_tokenAddress, _tokenId, _roleId); + } + + function setRoleApprovalForAll(address _tokenAddress, address _operator, bool _approved) external override { + tokenApprovals[msg.sender][_tokenAddress][_operator] = _approved; + emit RoleApprovalForAll(_tokenAddress, _operator, _approved); + } + + /** ERC-7432 View Functions **/ + + function recipientOf( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (address recipient_) { + address _wrappedTokenAddress = IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_tokenAddress); + if (_wrappedTokenAddress == address(0) || _roleId != USER_ROLE) { + return address(0); + } + return IERC4907(_wrappedTokenAddress).userOf(_tokenId); + } + + function roleData(address, uint256, bytes32) external pure returns (bytes memory) { + return ''; + } + + function roleExpirationDate( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (uint64 expirationDate_) { + address _wrappedTokenAddress = IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_tokenAddress); + if (_wrappedTokenAddress == address(0) || _roleId != USER_ROLE) { + return 0; + } + return uint64(IERC4907(_wrappedTokenAddress).userExpires(_tokenId)); + } + + function isRoleRevocable( + address _tokenAddress, + uint256 _tokenId, + bytes32 _roleId + ) external view returns (bool revocable_) { + return + _roleId == USER_ROLE && + isRevocableRole[_tokenAddress][_tokenId] && + IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_tokenAddress) != address(0); + } + + function isRoleApprovedForAll(address _tokenAddress, address _owner, address _operator) public view returns (bool) { + return + _operator == IOriumWrapperManager(oriumWrapperManager).getMarketplaceAddressOf(_tokenAddress) || + tokenApprovals[_owner][_tokenAddress][_operator]; + } + + /** ERC-7432 Vault Extension Functions **/ + + function withdraw(address _tokenAddress, uint256 _tokenId) external override { + address _wrappedTokenAddress = IOriumWrapperManager(oriumWrapperManager).getWrappedTokenOf(_tokenAddress); + require(_wrappedTokenAddress != address(0), 'ERC7432WrapperForERC4907: token not supported'); + + address originalOwner = originalOwners[_tokenAddress][_tokenId]; + require( + originalOwner == msg.sender || isRoleApprovedForAll(_tokenAddress, originalOwner, msg.sender), + 'ERC7432WrapperForERC4907: sender must be owner or approved' + ); + + require( + isRevocableRole[_tokenAddress][_tokenId] || + IERC4907(_wrappedTokenAddress).userExpires(_tokenId) < block.timestamp, + 'ERC7432WrapperForERC4907: token is not withdrawable' + ); + + delete originalOwners[_tokenAddress][_tokenId]; + delete isRevocableRole[_tokenAddress][_tokenId]; + IWrapNFT(_wrappedTokenAddress).redeem(_tokenId); + IERC721(_tokenAddress).transferFrom(address(this), originalOwner, _tokenId); + emit Withdraw(originalOwner, _tokenAddress, _tokenId); + } + + function ownerOf(address _tokenAddress, uint256 _tokenId) external view returns (address owner_) { + return originalOwners[_tokenAddress][_tokenId]; + } + + /** ERC-165 Functions **/ + + function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { + return + interfaceId == type(IERC7432).interfaceId || + interfaceId == type(IERC7432VaultExtension).interfaceId || + interfaceId == type(IERC721Receiver).interfaceId; + } + + /** Internal Functions **/ + + /// @notice Updates originalOwner, validates the sender and deposits NFT (if not deposited yet). + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @param _wrappedTokenAddress The wrapped token address. + /// @return originalOwner_ The original owner of the NFT. + function _depositNft( + address _tokenAddress, + uint256 _tokenId, + address _wrappedTokenAddress + ) internal returns (address originalOwner_) { + address _ownerOfOriginalToken = IERC721(_tokenAddress).ownerOf(_tokenId); + if (_ownerOfOriginalToken == _wrappedTokenAddress) { + // if NFT is in the wrapper contract, this contract should be the NFT owner + require( + IERC721(_wrappedTokenAddress).ownerOf(_tokenId) == address(this), + 'ERC7432WrapperForERC4907: contract does not own wrapped token' + ); + + originalOwner_ = originalOwners[_tokenAddress][_tokenId]; + require( + originalOwner_ == msg.sender || isRoleApprovedForAll(_tokenAddress, originalOwner_, msg.sender), + 'ERC7432WrapperForERC4907: sender must be owner or approved' + ); + } else { + // if NFT is not in the wrapper contract, wrap it and store the original owner + require( + _ownerOfOriginalToken == msg.sender || + isRoleApprovedForAll(_tokenAddress, _ownerOfOriginalToken, msg.sender), + 'ERC7432WrapperForERC4907: sender must be owner or approved' + ); + IERC721(_tokenAddress).transferFrom(_ownerOfOriginalToken, address(this), _tokenId); + IERC721(_tokenAddress).approve(_wrappedTokenAddress, _tokenId); + IWrapNFT(_wrappedTokenAddress).stake(_tokenId); + originalOwners[_tokenAddress][_tokenId] = _ownerOfOriginalToken; + originalOwner_ = _ownerOfOriginalToken; + } + } + + /// @notice Returns the account approved to call the revokeRole function. Reverts otherwise. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @param _recipient The user that received the role. + /// @return caller_ The approved account. + function _getApprovedCaller( + address _tokenAddress, + uint256 _tokenId, + address _recipient + ) internal view returns (address caller_) { + if (msg.sender == _recipient || isRoleApprovedForAll(_tokenAddress, _recipient, msg.sender)) { + return _recipient; + } + address originalOwner = originalOwners[_tokenAddress][_tokenId]; + if (msg.sender == originalOwner || isRoleApprovedForAll(_tokenAddress, originalOwner, msg.sender)) { + return originalOwner; + } + revert('ERC7432WrapperForERC4907: sender is not recipient, owner or approved'); + } +} diff --git a/contracts/ERC7432/NftRolesRegistryVault.sol b/contracts/ERC7432/NftRolesRegistryVault.sol index f9f30a3..63fa748 100644 --- a/contracts/ERC7432/NftRolesRegistryVault.sol +++ b/contracts/ERC7432/NftRolesRegistryVault.sol @@ -151,6 +151,10 @@ contract NftRolesRegistryVault is IERC7432, IERC7432VaultExtension { emit Withdraw(originalOwner, _tokenAddress, _tokenId); } + function ownerOf(address _tokenAddress, uint256 _tokenId) external view returns (address owner_) { + return originalOwners[_tokenAddress][_tokenId]; + } + /** ERC-165 Functions **/ function supportsInterface(bytes4 interfaceId) external view virtual override returns (bool) { diff --git a/contracts/OriumWrapperManager.sol b/contracts/OriumWrapperManager.sol new file mode 100644 index 0000000..0b2e701 --- /dev/null +++ b/contracts/OriumWrapperManager.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { Initializable } from '@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol'; +import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; +import { IOriumWrapperManager } from './interfaces/IOriumWrapperManager.sol'; + +contract OriumWrapperManager is Initializable, OwnableUpgradeable, IOriumWrapperManager { + address public marketplaceAddress; + + // tokenAddress => wrappedTokenAddress + mapping(address => address) public wrappedTokenOf; + + // wrappedTokenAddress => tokenAddress + mapping(address => address) public originalTokenOf; + + // tokenAddress => maxDuration + mapping(address => uint256) public maxDurationOf; + + /** External Functions **/ + + function initialize(address _owner, address _marketplaceAddress) external initializer { + __Ownable_init(); + transferOwnership(_owner); + marketplaceAddress = _marketplaceAddress; + } + + function setMarketplaceAddress(address _marketplaceAddress) external onlyOwner { + marketplaceAddress = _marketplaceAddress; + } + + function mapToken(address _tokenAddress, address _wrappedTokenAddress) external onlyOwner { + wrappedTokenOf[_tokenAddress] = _wrappedTokenAddress; + originalTokenOf[_wrappedTokenAddress] = _tokenAddress; + } + + function unmapToken(address _tokenAddress) external onlyOwner { + address _wrappedTokenAddress = wrappedTokenOf[_tokenAddress]; + delete wrappedTokenOf[_tokenAddress]; + delete originalTokenOf[_wrappedTokenAddress]; + } + + function setMaxDuration(address _tokenAddress, uint256 _maxDuration) external onlyOwner { + maxDurationOf[_tokenAddress] = _maxDuration; + } + + /** View Functions **/ + + function getMarketplaceAddressOf(address) external view override returns (address) { + return marketplaceAddress; + } + + function getWrappedTokenOf(address _tokenAddress) external view returns (address) { + return wrappedTokenOf[_tokenAddress]; + } + + function getOriginalTokenOf(address _wrappedTokenAddress) external view returns (address) { + return originalTokenOf[_wrappedTokenAddress]; + } + + function getMaxDurationOf(address _tokenAddress) external view returns (uint256) { + return maxDurationOf[_tokenAddress]; + } +} diff --git a/contracts/interfaces/DoubleProtocol/IWrapNFT.sol b/contracts/interfaces/DoubleProtocol/IWrapNFT.sol new file mode 100644 index 0000000..a2e03c9 --- /dev/null +++ b/contracts/interfaces/DoubleProtocol/IWrapNFT.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +import { IERC721 } from '@openzeppelin/contracts/token/ERC721/IERC721.sol'; +import { IERC721Receiver } from '@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol'; +import { IERC4907 } from '../IERC4907.sol'; + +interface IWrapNFT is IERC721, IERC721Receiver, IERC4907 { + event Stake(address msgSender, address nftAddress, uint256 tokenId); + + event Redeem(address msgSender, address nftAddress, uint256 tokenId); + + function originalAddress() external view returns (address); + + function stake(uint256 tokenId) external returns (uint256); + + function redeem(uint256 tokenId) external; +} diff --git a/contracts/interfaces/IERC4907.sol b/contracts/interfaces/IERC4907.sol new file mode 100644 index 0000000..baaf5fe --- /dev/null +++ b/contracts/interfaces/IERC4907.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +interface IERC4907 { + // Logged when the user of an NFT is changed or expires is changed + /// @notice Emitted when the `user` of an NFT or the `expires` of the `user` is changed + /// The zero address for user indicates that there is no user address + event UpdateUser(uint256 indexed tokenId, address indexed user, uint64 expires); + + /// @notice set the user and expires of an NFT + /// @dev The zero address indicates there is no user + /// Throws if `tokenId` is not valid NFT + /// @param user The new user of the NFT + /// @param expires UNIX timestamp, The new user could use the NFT before expires + function setUser(uint256 tokenId, address user, uint64 expires) external; + + /// @notice Get the user address of an NFT + /// @dev The zero address indicates that there is no user or the user is expired + /// @param tokenId The NFT to get the user address for + /// @return The user address for this NFT + function userOf(uint256 tokenId) external view returns (address); + + /// @notice Get the user expires of an NFT + /// @dev The zero value indicates that there is no user + /// @param tokenId The NFT to get the user expires for + /// @return The user expires for this NFT + function userExpires(uint256 tokenId) external view returns (uint256); +} diff --git a/contracts/interfaces/IERC7432VaultExtension.sol b/contracts/interfaces/IERC7432VaultExtension.sol index 0f35335..1e56014 100644 --- a/contracts/interfaces/IERC7432VaultExtension.sol +++ b/contracts/interfaces/IERC7432VaultExtension.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.9; /// @title ERC-7432 Vault Extension /// @dev See https://eips.ethereum.org/EIPS/eip-7432 -/// Note: the ERC-165 identifier for this interface is 0xf3fef3a3. +/// Note: the ERC-165 identifier for this interface is 0xecd7217f. interface IERC7432VaultExtension { /** Events **/ @@ -21,4 +21,12 @@ interface IERC7432VaultExtension { /// @param _tokenAddress The token address. /// @param _tokenId The token identifier. function withdraw(address _tokenAddress, uint256 _tokenId) external; + + /** View Functions **/ + + /// @notice Retrieves the owner of a deposited NFT. + /// @param _tokenAddress The token address. + /// @param _tokenId The token identifier. + /// @return owner_ The owner of the token. + function ownerOf(address _tokenAddress, uint256 _tokenId) external view returns (address owner_); } diff --git a/contracts/interfaces/IOriumWrapperManager.sol b/contracts/interfaces/IOriumWrapperManager.sol new file mode 100644 index 0000000..8284d03 --- /dev/null +++ b/contracts/interfaces/IOriumWrapperManager.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: CC0-1.0 + +pragma solidity 0.8.9; + +interface IOriumWrapperManager { + /** External Functions **/ + + /// @notice Maps a token to a wrapped token. + /// @param _tokenAddress The token address. + /// @param _wrappedTokenAddress The wrapped token address. + function mapToken(address _tokenAddress, address _wrappedTokenAddress) external; + + /// @notice Unmaps a token (removes association from storage). + /// @param _tokenAddress The token address. + function unmapToken(address _tokenAddress) external; + + /// @notice Sets the maximum duration for a token. + /// @param _tokenAddress The token address. + /// @param _maxDuration The maximum duration. + function setMaxDuration(address _tokenAddress, uint256 _maxDuration) external; + + /// @notice Sets the marketplace address. + /// @param _marketplaceAddress The marketplace address. + function setMarketplaceAddress(address _marketplaceAddress) external; + + /** View Functions **/ + + /// @notice Gets the marketplace address of a token. + /// @param _tokenAddress The token address. + /// @return The marketplace address. + function getMarketplaceAddressOf(address _tokenAddress) external view returns (address); + + /// @notice Gets the wrapped token of a token. + /// @param _tokenAddress The token address. + /// @return The wrapped token address. + function getWrappedTokenOf(address _tokenAddress) external view returns (address); + + /// @notice Gets the original token of a wrapped token. + /// @param _wrappedTokenAddress The wrapped token address. + /// @return The original token address. + function getOriginalTokenOf(address _wrappedTokenAddress) external view returns (address); + + /// @notice Gets the maximum duration of a token. + /// @param _tokenAddress The token address. + /// @return The maximum duration. + function getMaxDurationOf(address _tokenAddress) external view returns (uint256); +} diff --git a/hardhat.config.ts b/hardhat.config.ts index 468731a..321ffc1 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -20,11 +20,12 @@ const { POLYGONSCAN_API_KEY, ETHER_SCAN_API_KEY, POLYGON_PROVIDER_URL, - MUMBAI_PROVIDER_URL, - GOERLI_PROVIDER_URL, + SEPOLIA_TESTNET_PROVIDER_URL, CRONOS_TESTNET_PROVIDER_URL, + MOONBEAM_PROVIDER_URL, CRONOS_PROVIDER_URL, CRONOSSCAN_API_KEY, + MOONSCAN_API_KEY, } = process.env const BASE_CONFIG = { @@ -66,10 +67,10 @@ const BASE_CONFIG = { etherscan: { apiKey: { polygon: POLYGONSCAN_API_KEY, - polygonMumbai: POLYGONSCAN_API_KEY, - goerli: ETHER_SCAN_API_KEY, + sepolia: ETHER_SCAN_API_KEY, cronosTestnet: CRONOSSCAN_API_KEY, cronos: CRONOSSCAN_API_KEY, + moonbeam: MOONSCAN_API_KEY, }, customChains: [ { @@ -113,19 +114,14 @@ const PROD_CONFIG = { blockNumber: 45752368, }, }, - mumbai: { - chainId: 80001, - url: MUMBAI_PROVIDER_URL, - accounts: [DEV_PRIVATE_KEY], - }, polygon: { chainId: 137, url: POLYGON_PROVIDER_URL, accounts: [PROD_PRIVATE_KEY], }, - goerli: { - chainId: 5, - url: GOERLI_PROVIDER_URL, + cronos: { + chainId: 25, + url: CRONOS_PROVIDER_URL, accounts: [DEV_PRIVATE_KEY], }, cronosTestnet: { @@ -133,9 +129,14 @@ const PROD_CONFIG = { url: CRONOS_TESTNET_PROVIDER_URL, accounts: [DEV_PRIVATE_KEY], }, - cronos: { - chainId: 25, - url: CRONOS_PROVIDER_URL, + sepoliaTestnet: { + chainId: 11155111, + url: SEPOLIA_TESTNET_PROVIDER_URL, + accounts: [DEV_PRIVATE_KEY], + }, + moonbeam: { + chainId: 1284, + url: MOONBEAM_PROVIDER_URL, accounts: [DEV_PRIVATE_KEY], }, }, diff --git a/scripts/roles-registry/04-deploy-erc7432-wrapper-for-erc4907.ts b/scripts/roles-registry/04-deploy-erc7432-wrapper-for-erc4907.ts new file mode 100644 index 0000000..78a8344 --- /dev/null +++ b/scripts/roles-registry/04-deploy-erc7432-wrapper-for-erc4907.ts @@ -0,0 +1,72 @@ +import { ethers, network, upgrades } from 'hardhat' +import { AwsKmsSigner } from '@govtechsg/ethers-aws-kms-signer' +import { updateJsonFile } from '../../utils/json' +import { confirmOrDie, print, colors } from '../../utils/misc' + +const kmsCredentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'AKIAxxxxxxxxxxxxxxxx', // credentials for your IAM user with KMS access + secretAccessKey: process.env.AWS_ACCESS_KEY_SECRET || 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // credentials for your IAM user with KMS access + region: 'us-east-1', // region of your KMS key + keyId: process.env.AWS_KMS_KEY_ID || 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', // KMS key id +} + +const NETWORK = network.name +const IMMUTABLE_CONTRACT_NAME = 'ERC7432WrapperForERC4907' +const UPGRADABLE_CONTRACT_NAME = 'OriumWrapperManager' + +const networkConfig: any = network.config +const provider = new ethers.providers.JsonRpcProvider(networkConfig.url || '') + +const deployer = new AwsKmsSigner(kmsCredentials).connect(provider) + +async function main() { + const deployerAddress = await deployer.getAddress() + + /** Deploy OriumWrapperManager **/ + + await confirmOrDie( + `Deploying ${UPGRADABLE_CONTRACT_NAME} contract on: ${NETWORK} network with ${deployerAddress}. Continue?`, + ) + const ContractFactory = await ethers.getContractFactory(UPGRADABLE_CONTRACT_NAME, { signer: deployer }) + const INITIALIZER_ARGUMENTS = [deployerAddress, ethers.constants.AddressZero] + const contract = await upgrades.deployProxy(ContractFactory, INITIALIZER_ARGUMENTS) + await contract.deployed() + print(colors.success, `${UPGRADABLE_CONTRACT_NAME} deployed to: ${contract.address}`) + + print(colors.highlight, 'Updating config files...') + const deploymentInfo = { + [UPGRADABLE_CONTRACT_NAME]: { + address: contract.address, + operator: deployerAddress, + implementation: await upgrades.erc1967.getImplementationAddress(contract.address), + proxyAdmin: await upgrades.erc1967.getAdminAddress(contract.address), + }, + } + + console.log('deploymentInfo', deploymentInfo) + + updateJsonFile(`addresses/${NETWORK}/index.json`, deploymentInfo) + + print(colors.success, 'Config files updated!') + + /** Deploy ERC7432WrapperForERC4907 **/ + + await confirmOrDie( + `Deploying ${IMMUTABLE_CONTRACT_NAME} contract on: ${NETWORK} network with ${deployerAddress}. Continue?`, + ) + + const ERC7432WrapperForERC4907Factory = await ethers.getContractFactory(IMMUTABLE_CONTRACT_NAME, { signer: deployer }) + const ERC7432WrapperForERC4907 = await ERC7432WrapperForERC4907Factory.deploy(contract.address) + await ERC7432WrapperForERC4907.deployed() + + console.log(`${IMMUTABLE_CONTRACT_NAME} deployed at: ${ERC7432WrapperForERC4907.address}`) +} + +main() + .then(async () => { + console.log('All done!') + }) + .catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/scripts/roles-registry/05-wrapper-manager-functions.ts b/scripts/roles-registry/05-wrapper-manager-functions.ts new file mode 100644 index 0000000..3c9e4bb --- /dev/null +++ b/scripts/roles-registry/05-wrapper-manager-functions.ts @@ -0,0 +1,57 @@ +import { ethers, network } from 'hardhat' +import { AwsKmsSigner } from '@govtechsg/ethers-aws-kms-signer' +import { confirmOrDie, print, colors } from '../../utils/misc' +import config, { Network } from '../../addresses' + +const kmsCredentials = { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || 'AKIAxxxxxxxxxxxxxxxx', // credentials for your IAM user with KMS access + secretAccessKey: process.env.AWS_ACCESS_KEY_SECRET || 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // credentials for your IAM user with KMS access + region: 'us-east-1', // region of your KMS key + keyId: process.env.AWS_KMS_KEY_ID || 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', // KMS key id +} + +const ContractName = 'OriumWrapperManager' +const networkConfig: any = network.config +const provider = new ethers.providers.JsonRpcProvider(networkConfig.url || '') +const deployer = new AwsKmsSigner(kmsCredentials).connect(provider) + +async function main() { + const NETWORK = network.name as Network + const deployerAddress = await deployer.getAddress() + const WrapperManagerAddress = config[NETWORK][ContractName].address + const WrapperManager = await ethers.getContractAt(ContractName, WrapperManagerAddress, deployer) + + let tx + await confirmOrDie( + `Updating ${ContractName} on ${NETWORK} [${WrapperManagerAddress}] with deployer ${deployerAddress}. Continue?`, + ) + + // print(colors.highlight, 'Updating marketplace address...') + // const marketplaceAddress = '' + // tx = await WrapperManager.setMarketplaceAddress(marketplaceAddress) + // await tx.wait() + // print(colors.success, `Updated marketplace address ${marketplaceAddress}`) + + print(colors.highlight, 'Updating token mapping...') + const tokenAddress = '0xcb13945ca8104f813992e4315f8ffefe64ac49ca' + const wrapperTokenAddress = '0xB7fdD27a8Df011816205a6e3cAA097DC4D8C2C5d' + tx = await WrapperManager.mapToken(tokenAddress, wrapperTokenAddress) + await tx.wait() + print(colors.success, `Updated token ${tokenAddress} with wrapper ${wrapperTokenAddress}`) + + print(colors.highlight, 'Updating max duration...') + const tokenToUpdate = '0xcb13945ca8104f813992e4315f8ffefe64ac49ca' + const maxDuration = 60 * 60 * 24 * 30 * 3 // 3 months + tx = await WrapperManager.setMaxDuration(tokenToUpdate, maxDuration) + await tx.wait() + print(colors.success, `Updated token ${tokenAddress} with wrapper ${wrapperTokenAddress}`) +} + +main() + .then(async () => { + console.log('All done!') + }) + .catch(error => { + console.error(error) + process.exitCode = 1 + }) diff --git a/test/ERC7432/ERC7432WrapperForERC4907.spec.ts b/test/ERC7432/ERC7432WrapperForERC4907.spec.ts new file mode 100644 index 0000000..5efecdd --- /dev/null +++ b/test/ERC7432/ERC7432WrapperForERC4907.spec.ts @@ -0,0 +1,557 @@ +import { ethers, upgrades, network } from 'hardhat' +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { Contract } from 'ethers' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' +import { Role } from '../types' +import { buildRole, getExpiredDate } from './mockData' +import { expect } from 'chai' +import { generateErc165InterfaceId, ROLE, THREE_MONTHS } from '../helpers' +import { beforeEach } from 'mocha' +import { IERC7432__factory, IERC7432VaultExtension__factory, IERC721Receiver__factory } from '../../typechain-types' + +const UserRole = 'User()' +const NovaCreedTokenAddress = '0x8a514a40ed06fc44b6e0c9875cdd58e20063d10e' +const WrappedNovaCreedTokenAddress = '0xc30Dedd81fE3cD756bFFeE41199E86B0C3b10218' +const AccountWithNovaCreedTokens = '0x27837ffd62144628e75bab1b63eb92cca3b3c05b' +const { AddressZero } = ethers.constants + +describe('ERC7432WrapperForERC4907', async () => { + let ERC7432WrapperForERC4907: Contract + let Erc721Token: Contract + let WrappedErc721Token: Contract + let owner: SignerWithAddress + let operator: SignerWithAddress + let recipient: SignerWithAddress + let anotherUser: SignerWithAddress + let marketplaceAccount: SignerWithAddress + let role: Role + + async function deployContracts() { + const signers = await ethers.getSigners() + operator = signers[0] + recipient = signers[1] + anotherUser = signers[2] + marketplaceAccount = signers[3] + + await network.provider.request({ + method: 'hardhat_impersonateAccount', + params: [AccountWithNovaCreedTokens], + }) + await operator.sendTransaction({ + to: AccountWithNovaCreedTokens, + value: ethers.utils.parseEther('100'), + }) + + owner = await ethers.getSigner(AccountWithNovaCreedTokens) + + const OriumWrapperManagerFactory = await ethers.getContractFactory('OriumWrapperManager') + const OriumWrapperManagerProxy = await upgrades.deployProxy(OriumWrapperManagerFactory, [ + operator.address, + marketplaceAccount.address, + ]) + + // set wrapper address + await expect( + OriumWrapperManagerProxy.connect(operator).mapToken(NovaCreedTokenAddress, WrappedNovaCreedTokenAddress), + ).to.not.be.reverted + + // set max duration + await expect(OriumWrapperManagerProxy.connect(operator).setMaxDuration(NovaCreedTokenAddress, THREE_MONTHS)).to.not + .be.reverted + + const ERC7432WrapperForERC4907Factory = await ethers.getContractFactory('ERC7432WrapperForERC4907') + ERC7432WrapperForERC4907 = await ERC7432WrapperForERC4907Factory.deploy(OriumWrapperManagerProxy.address) + + Erc721Token = await ethers.getContractAt('IERC721', NovaCreedTokenAddress) + WrappedErc721Token = await ethers.getContractAt('IWrapNFT', WrappedNovaCreedTokenAddress) + + const block = await ethers.provider.getBlock('latest') + role = await buildRole({ + roleId: UserRole, + tokenAddress: NovaCreedTokenAddress, + tokenId: 64, + expirationDate: block.timestamp + THREE_MONTHS, + }) + } + + async function depositNftAndGrantRole({ recipient = AddressZero }) { + await expect(Erc721Token.connect(owner).approve(ERC7432WrapperForERC4907.address, role.tokenId)).to.not.be.reverted + + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, recipient })) + .to.emit(ERC7432WrapperForERC4907, 'RoleGranted') + .withArgs( + role.tokenAddress, + role.tokenId, + role.roleId, + owner.address, + recipient, + role.expirationDate, + role.revocable, + role.data, + ) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, recipient, role.expirationDate) + } + + beforeEach(async () => { + await loadFixture(deployContracts) + }) + + describe('grantRole', async () => { + it("should revert when role is not 'User()'", async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, roleId: ROLE })).to.be.revertedWith( + "ERC7432WrapperForERC4907: only 'User()' role is allowed", + ) + }) + + it('should revert when token is not supported', async () => { + const tokenAddress = AddressZero + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, tokenAddress })).to.be.revertedWith( + 'ERC7432WrapperForERC4907: token not supported', + ) + }) + + it('should revert when expiration date is in the past', async () => { + const expirationDate = await getExpiredDate() + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, expirationDate })).to.be.revertedWith( + 'ERC7432WrapperForERC4907: invalid expiration date', + ) + }) + + it('should revert when expiration date is longer than allowed', async () => { + const block = await ethers.provider.getBlock('latest') + const expirationDate = block.timestamp + THREE_MONTHS + 1 + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, expirationDate })).to.be.revertedWith( + 'ERC7432WrapperForERC4907: invalid expiration date', + ) + }) + + it('should revert when NFT is wrapped but not deposited in the contract', async () => { + await Erc721Token.connect(owner).approve(WrappedErc721Token.address, role.tokenId) + await expect(WrappedErc721Token.connect(owner).stake(role.tokenId)).to.not.be.reverted + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole(role)).to.be.revertedWith( + 'ERC7432WrapperForERC4907: contract does not own wrapped token', + ) + }) + + describe('when NFT is not deposited', async () => { + it('should revert when sender is not approved or owner', async () => { + await expect(ERC7432WrapperForERC4907.connect(anotherUser).grantRole(role)).to.be.revertedWith( + 'ERC7432WrapperForERC4907: sender must be owner or approved', + ) + }) + + it('should revert when contract is not approved to transfer NFT', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole(role)).to.be.revertedWith( + 'ERC721: transfer caller is not owner nor approved', + ) + }) + + it('should revert when sender is role approved, but contract is not approved to transfer NFT', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll( + role.tokenAddress, + anotherUser.address, + true, + ) + await expect(ERC7432WrapperForERC4907.connect(anotherUser).grantRole(role)).to.be.revertedWith( + 'ERC721: transfer caller is not owner nor approved', + ) + }) + + it('should grant role when sender is NFT owner', async () => { + await depositNftAndGrantRole({}) + }) + + it('should grant role when sender is approved', async () => { + await Erc721Token.connect(owner).approve(ERC7432WrapperForERC4907.address, role.tokenId) + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll( + role.tokenAddress, + anotherUser.address, + true, + ) + + await expect(ERC7432WrapperForERC4907.connect(anotherUser).grantRole(role)) + .to.emit(ERC7432WrapperForERC4907, 'RoleGranted') + .withArgs( + role.tokenAddress, + role.tokenId, + role.roleId, + owner.address, + role.recipient, + role.expirationDate, + role.revocable, + role.data, + ) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, role.recipient, role.expirationDate) + }) + }) + + describe('when NFT is deposited', async () => { + beforeEach(async () => { + await depositNftAndGrantRole({}) + }) + + it('should revert when sender is not approved nor original owner', async () => { + await expect(ERC7432WrapperForERC4907.connect(anotherUser).grantRole(role)).to.be.revertedWith( + 'ERC7432WrapperForERC4907: sender must be owner or approved', + ) + }) + + it('should grant role when sender is original owner', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole(role)) + .to.emit(ERC7432WrapperForERC4907, 'RoleGranted') + .withArgs( + role.tokenAddress, + role.tokenId, + role.roleId, + owner.address, + role.recipient, + role.expirationDate, + role.revocable, + role.data, + ) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, role.recipient, role.expirationDate) + .to.not.emit(Erc721Token, 'Transfer') + .to.not.emit(WrappedErc721Token, 'Transfer') + }) + + it('should grant role when sender is approved', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll( + role.tokenAddress, + anotherUser.address, + true, + ) + await expect(ERC7432WrapperForERC4907.connect(anotherUser).grantRole(role)) + .to.emit(ERC7432WrapperForERC4907, 'RoleGranted') + .withArgs( + role.tokenAddress, + role.tokenId, + role.roleId, + owner.address, + role.recipient, + role.expirationDate, + role.revocable, + role.data, + ) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, role.recipient, role.expirationDate) + .to.not.emit(Erc721Token, 'Transfer') + .to.not.emit(WrappedErc721Token, 'Transfer') + }) + + it('should revert when there is a non-expired and non-revocable role', async () => { + await ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, revocable: false }) + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole(role)).to.be.revertedWith( + 'ERC7432WrapperForERC4907: role must be expired or revocable', + ) + }) + }) + }) + + describe('revokeRole', async () => { + beforeEach(async () => { + await depositNftAndGrantRole({ recipient: recipient.address }) + }) + + it("should revert when role is not 'User()'", async () => { + await expect( + ERC7432WrapperForERC4907.connect(owner).revokeRole(role.tokenAddress, role.tokenId, ROLE), + ).to.be.revertedWith("ERC7432WrapperForERC4907: only 'User()' role is allowed") + }) + + it('should revert when token is not supported', async () => { + await expect( + ERC7432WrapperForERC4907.connect(owner).revokeRole(AddressZero, role.tokenId, role.roleId), + ).to.be.revertedWith('ERC7432WrapperForERC4907: token not supported') + }) + + it('should revert when sender is not owner, recipient or approved', async () => { + await expect( + ERC7432WrapperForERC4907.connect(anotherUser).revokeRole(role.tokenAddress, 1, role.roleId), + ).to.be.revertedWith('ERC7432WrapperForERC4907: sender is not recipient, owner or approved') + }) + + it('should revert when sender is owner but role is not revocable nor expired', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, revocable: false })) + + await expect( + ERC7432WrapperForERC4907.connect(owner).revokeRole(role.tokenAddress, role.tokenId, role.roleId), + ).to.be.revertedWith('ERC7432WrapperForERC4907: role is not revocable nor expired') + }) + + it('should revoke role when sender is recipient', async () => { + await expect(ERC7432WrapperForERC4907.connect(recipient).revokeRole(role.tokenAddress, role.tokenId, role.roleId)) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should revoke role when sender is approved by recipient', async () => { + await ERC7432WrapperForERC4907.connect(recipient).setRoleApprovalForAll( + role.tokenAddress, + anotherUser.address, + true, + ) + await expect( + ERC7432WrapperForERC4907.connect(anotherUser).revokeRole(role.tokenAddress, role.tokenId, role.roleId), + ) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should revoke role when sender is owner (and role is revocable)', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).revokeRole(role.tokenAddress, role.tokenId, role.roleId)) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should revoke role when sender is owner, and role is not revocable but is expired', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, revocable: false })) + .to.emit(ERC7432WrapperForERC4907, 'RoleGranted') + .withArgs( + role.tokenAddress, + role.tokenId, + role.roleId, + owner.address, + role.recipient, + role.expirationDate, + false, + role.data, + ) + .to.not.emit(Erc721Token, 'Transfer') + await time.increase(THREE_MONTHS) + await expect(ERC7432WrapperForERC4907.connect(owner).revokeRole(role.tokenAddress, role.tokenId, role.roleId)) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should revoke role when sender is approved by owner (and role is revocable)', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll(role.tokenAddress, anotherUser.address, true) + await expect( + ERC7432WrapperForERC4907.connect(anotherUser).revokeRole(role.tokenAddress, role.tokenId, role.roleId), + ) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should revoke role when sender is approved both by owner and recipient, and role not revocable', async () => { + await expect( + ERC7432WrapperForERC4907.connect(owner).grantRole({ + ...role, + recipient: recipient.address, + revocable: false, + }), + ) + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll(role.tokenAddress, anotherUser.address, true) + await ERC7432WrapperForERC4907.connect(recipient).setRoleApprovalForAll( + role.tokenAddress, + anotherUser.address, + true, + ) + await expect( + ERC7432WrapperForERC4907.connect(anotherUser).revokeRole(role.tokenAddress, role.tokenId, role.roleId), + ) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + .to.emit(WrappedErc721Token, 'UpdateUser') + .withArgs(role.tokenId, AddressZero, 0) + }) + + it('should not delete original owner when revoking role', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).revokeRole(role.tokenAddress, role.tokenId, role.roleId)) + .to.emit(ERC7432WrapperForERC4907, 'RoleRevoked') + .withArgs(role.tokenAddress, role.tokenId, role.roleId) + + expect(await ERC7432WrapperForERC4907.originalOwners(role.tokenAddress, role.tokenId)).to.be.equal(owner.address) + }) + }) + + describe('withdraw', async () => { + beforeEach(async () => { + await depositNftAndGrantRole({ recipient: recipient.address }) + }) + + it('should revert when token is not supported', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).withdraw(AddressZero, role.roleId)).to.be.revertedWith( + 'ERC7432WrapperForERC4907: token not supported', + ) + }) + + it('should revert if token is not deposited', async () => { + await expect( + ERC7432WrapperForERC4907.connect(owner).withdraw(role.tokenAddress, role.tokenId + 1), + ).to.be.revertedWith('ERC7432WrapperForERC4907: sender must be owner or approved') + }) + + it('should revert if sender is not original owner or approved', async () => { + await expect( + ERC7432WrapperForERC4907.connect(anotherUser).withdraw(role.tokenAddress, role.tokenId), + ).to.be.revertedWith('ERC7432WrapperForERC4907: sender must be owner or approved') + }) + + it('should revert if role is not revocable and not expired', async () => { + await ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, revocable: false }) + await expect( + ERC7432WrapperForERC4907.connect(owner).withdraw(role.tokenAddress, role.tokenId), + ).to.be.revertedWith('ERC7432WrapperForERC4907: token is not withdrawable') + }) + + it('should withdraw if sender is owner and NFT is withdrawable', async () => { + await expect(ERC7432WrapperForERC4907.connect(owner).withdraw(role.tokenAddress, role.tokenId)) + .to.emit(ERC7432WrapperForERC4907, 'Withdraw') + .withArgs(owner.address, role.tokenAddress, role.tokenId) + .to.emit(WrappedErc721Token, 'Redeem') + .withArgs(ERC7432WrapperForERC4907.address, role.tokenAddress, role.tokenId) + .to.emit(Erc721Token, 'Transfer') + .withArgs(ERC7432WrapperForERC4907.address, owner.address, role.tokenId) + }) + + it('should revert if role is not revocable, but is expired', async () => { + await ERC7432WrapperForERC4907.connect(owner).grantRole({ ...role, revocable: false }) + await time.increase(THREE_MONTHS) + await expect(ERC7432WrapperForERC4907.connect(owner).withdraw(role.tokenAddress, role.tokenId)) + .to.emit(ERC7432WrapperForERC4907, 'Withdraw') + .withArgs(owner.address, role.tokenAddress, role.tokenId) + .to.emit(WrappedErc721Token, 'Redeem') + .withArgs(ERC7432WrapperForERC4907.address, role.tokenAddress, role.tokenId) + .to.emit(Erc721Token, 'Transfer') + .withArgs(ERC7432WrapperForERC4907.address, owner.address, role.tokenId) + }) + + it('should withdraw if sender is approved and NFT is withdrawable', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll(role.tokenAddress, anotherUser.address, true) + await expect(ERC7432WrapperForERC4907.connect(anotherUser).withdraw(role.tokenAddress, role.tokenId)) + .to.emit(ERC7432WrapperForERC4907, 'Withdraw') + .withArgs(owner.address, role.tokenAddress, role.tokenId) + .to.emit(WrappedErc721Token, 'Redeem') + .withArgs(ERC7432WrapperForERC4907.address, role.tokenAddress, role.tokenId) + .to.emit(Erc721Token, 'Transfer') + .withArgs(ERC7432WrapperForERC4907.address, owner.address, role.tokenId) + }) + }) + + describe('view functions', async () => { + describe('when NFT is not deposited', async () => { + it('recipientOf should return default value', async () => { + expect(await ERC7432WrapperForERC4907.recipientOf(role.tokenAddress, role.tokenId, role.roleId)).to.be.equal( + AddressZero, + ) + }) + + it('roleData should return default value', async () => { + expect(await ERC7432WrapperForERC4907.roleData(role.tokenAddress, role.tokenId, role.roleId)).to.be.equal('0x') + }) + + it('roleExpirationDate should return default value', async () => { + expect( + await ERC7432WrapperForERC4907.roleExpirationDate(role.tokenAddress, role.tokenId, role.roleId), + ).to.be.equal(0) + }) + + it('isRoleRevocable should return default value', async () => { + expect(await ERC7432WrapperForERC4907.isRoleRevocable(role.tokenAddress, role.tokenId, role.roleId)).to.be.false + }) + }) + + describe('when NFT is deposited', async () => { + beforeEach(async () => { + await depositNftAndGrantRole({ recipient: recipient.address }) + }) + + it('ownerOf should return value from mapping', async () => { + expect(await ERC7432WrapperForERC4907.ownerOf(role.tokenAddress, role.tokenId)).to.be.equal(owner.address) + }) + + it('recipientOf should return value from mapping', async () => { + expect(await ERC7432WrapperForERC4907.recipientOf(role.tokenAddress, role.tokenId, role.roleId)).to.be.equal( + recipient.address, + ) + }) + + it('roleExpirationDate should the expiration date of the role', async () => { + expect( + await ERC7432WrapperForERC4907.roleExpirationDate(role.tokenAddress, role.tokenId, role.roleId), + ).to.be.equal(role.expirationDate) + }) + + it('isRoleRevocable should whether the role is revocable', async () => { + expect(await ERC7432WrapperForERC4907.isRoleRevocable(role.tokenAddress, role.tokenId, role.roleId)).to.be.true + }) + + describe('when tokenAddress or role are not supported', async () => { + it('recipientOf should return default value', async () => { + expect(await ERC7432WrapperForERC4907.recipientOf(AddressZero, role.tokenId, role.roleId)).to.be.equal( + AddressZero, + ) + expect(await ERC7432WrapperForERC4907.recipientOf(role.tokenAddress, role.tokenId, ROLE)).to.be.equal( + AddressZero, + ) + }) + + it('roleData should return default value', async () => { + expect(await ERC7432WrapperForERC4907.roleData(AddressZero, role.tokenId, role.roleId)).to.be.equal('0x') + expect(await ERC7432WrapperForERC4907.roleData(role.tokenAddress, role.tokenId, ROLE)).to.be.equal('0x') + }) + + it('roleExpirationDate should return default value', async () => { + expect(await ERC7432WrapperForERC4907.roleExpirationDate(AddressZero, role.tokenId, role.roleId)).to.be.equal( + 0, + ) + expect(await ERC7432WrapperForERC4907.roleExpirationDate(role.tokenAddress, role.tokenId, ROLE)).to.be.equal( + 0, + ) + }) + }) + }) + }) + + describe('isRoleApprovedForAll', async () => { + it('should return false when not approved', async () => { + expect(await ERC7432WrapperForERC4907.isRoleApprovedForAll(role.tokenAddress, owner.address, anotherUser.address)) + .to.be.false + }) + + it('should return true when approved', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll(role.tokenAddress, anotherUser.address, true) + expect(await ERC7432WrapperForERC4907.isRoleApprovedForAll(role.tokenAddress, owner.address, anotherUser.address)) + .to.be.true + }) + + it('should always return true when operator is the marketplace', async () => { + await ERC7432WrapperForERC4907.connect(owner).setRoleApprovalForAll(role.tokenAddress, anotherUser.address, true) + expect(await ERC7432WrapperForERC4907.isRoleApprovedForAll(AddressZero, AddressZero, marketplaceAccount.address)) + .to.be.true + }) + }) + + describe('ERC-165', async () => { + it('should return true when IERC7432 identifier is provided', async () => { + const iface = IERC7432__factory.createInterface() + const ifaceId = generateErc165InterfaceId(iface) + expect(await ERC7432WrapperForERC4907.supportsInterface(ifaceId)).to.be.true + }) + + it('should return true when IERC7432VaultExtension identifier is provided', async () => { + const iface = IERC7432VaultExtension__factory.createInterface() + const ifaceId = generateErc165InterfaceId(iface) + expect(await ERC7432WrapperForERC4907.supportsInterface(ifaceId)).to.be.true + }) + + it('should return true when IERC721Receiver identifier is provided', async () => { + const iface = IERC721Receiver__factory.createInterface() + const ifaceId = generateErc165InterfaceId(iface) + expect(await ERC7432WrapperForERC4907.supportsInterface(ifaceId)).to.be.true + }) + }) +}) diff --git a/test/ERC7432/NftRolesRegistryVault.ts b/test/ERC7432/NftRolesRegistryVault.ts index d081187..6a30b6d 100644 --- a/test/ERC7432/NftRolesRegistryVault.ts +++ b/test/ERC7432/NftRolesRegistryVault.ts @@ -119,7 +119,7 @@ describe('NftRolesRegistryVault', () => { await depositNftAndGrantRole({}) }) - it('should revert when sender is not approved or original owner', async () => { + it('should revert when sender is not approved nor original owner', async () => { await expect(NftRolesRegistryVault.connect(anotherUser).grantRole(role)).to.be.revertedWith( 'NftRolesRegistryVault: sender must be owner or approved', ) @@ -292,8 +292,8 @@ describe('NftRolesRegistryVault', () => { }) describe('view functions', async () => { - describe('when NFT is deposited', async () => { - it('hasRole should return default value', async () => { + describe('when NFT is not deposited', async () => { + it('recipientOf should return default value', async () => { expect(await NftRolesRegistryVault.recipientOf(role.tokenAddress, role.tokenId, role.roleId)).to.be.equal( AddressZero, ) @@ -319,7 +319,11 @@ describe('NftRolesRegistryVault', () => { await depositNftAndGrantRole({ recipient: recipient.address }) }) - it('hasRole should return value from mapping', async () => { + it('ownerOf should return value from mapping', async () => { + expect(await NftRolesRegistryVault.ownerOf(role.tokenAddress, role.tokenId)).to.be.equal(owner.address) + }) + + it('recipientOf should return value from mapping', async () => { expect(await NftRolesRegistryVault.recipientOf(role.tokenAddress, role.tokenId, role.roleId)).to.be.equal( recipient.address, ) diff --git a/test/ERC7432/mockData.ts b/test/ERC7432/mockData.ts index 68e9c7a..293779c 100644 --- a/test/ERC7432/mockData.ts +++ b/test/ERC7432/mockData.ts @@ -16,7 +16,7 @@ export async function buildRole({ }): Promise { return { roleId: generateRoleId(roleId), - tokenAddress, + tokenAddress: ethers.utils.getAddress(tokenAddress), tokenId, recipient, expirationDate: expirationDate ? expirationDate : (await time.latest()) + ONE_DAY, diff --git a/test/OriumWrapperManager.spec.ts b/test/OriumWrapperManager.spec.ts new file mode 100644 index 0000000..ca48c3c --- /dev/null +++ b/test/OriumWrapperManager.spec.ts @@ -0,0 +1,70 @@ +import { beforeEach } from 'mocha' +import { expect } from 'chai' +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers' +import { Contract } from 'ethers' +import { ethers, upgrades } from 'hardhat' +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers' + +const { AddressZero } = ethers.constants + +describe('OriumWrapperManager', async () => { + let OriumWrapperManager: Contract + let operator: SignerWithAddress + let marketplaceAccount: SignerWithAddress + let token1: SignerWithAddress + let token2: SignerWithAddress + + async function deployContracts() { + const signers = await ethers.getSigners() + operator = signers[0] + marketplaceAccount = signers[1] + token1 = signers[2] + token2 = signers[3] + + const OriumWrapperManagerFactory = await ethers.getContractFactory('OriumWrapperManager') + OriumWrapperManager = await upgrades.deployProxy(OriumWrapperManagerFactory, [ + operator.address, + marketplaceAccount.address, + ]) + } + + beforeEach(async () => { + await loadFixture(deployContracts) + }) + + it('ensure that only owner can call setter functions', async () => { + await expect( + OriumWrapperManager.connect(marketplaceAccount).setMarketplaceAddress(marketplaceAccount.address), + ).to.be.revertedWith('Ownable: caller is not the owner') + await expect( + OriumWrapperManager.connect(marketplaceAccount).mapToken(token1.address, token2.address), + ).to.be.revertedWith('Ownable: caller is not the owner') + await expect(OriumWrapperManager.connect(marketplaceAccount).unmapToken(token1.address)).to.be.revertedWith( + 'Ownable: caller is not the owner', + ) + await expect( + OriumWrapperManager.connect(marketplaceAccount).setMaxDuration(token1.address, 1000), + ).to.be.revertedWith('Ownable: caller is not the owner') + }) + + it('should set and get marketplace address', async () => { + expect(await OriumWrapperManager.setMarketplaceAddress(marketplaceAccount.address)).to.not.be.reverted + expect(await OriumWrapperManager.getMarketplaceAddressOf(AddressZero)).to.equal(marketplaceAccount.address) + }) + + it('should set, get, and unset wrapper token', async () => { + expect(await OriumWrapperManager.mapToken(token1.address, token2.address)).to.not.be.reverted + expect(await OriumWrapperManager.getWrappedTokenOf(token1.address)).to.equal(token2.address) + expect(await OriumWrapperManager.getOriginalTokenOf(token2.address)).to.equal(token1.address) + + expect(await OriumWrapperManager.unmapToken(token1.address)).to.not.be.reverted + expect(await OriumWrapperManager.getWrappedTokenOf(token1.address)).to.equal(AddressZero) + expect(await OriumWrapperManager.getOriginalTokenOf(token2.address)).to.equal(AddressZero) + }) + + it('should set and get max duration', async () => { + const maxDuration = 1000 + expect(await OriumWrapperManager.setMaxDuration(token1.address, maxDuration)).to.not.be.reverted + expect(await OriumWrapperManager.getMaxDurationOf(token1.address)).to.equal(maxDuration) + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts index 2e60a83..c1a0840 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -3,6 +3,7 @@ import { expect } from 'chai' import { solidityKeccak256 } from 'ethers/lib/utils' export const ONE_DAY = 60 * 60 * 24 +export const THREE_MONTHS = ONE_DAY * 30 * 3 export const ROLE = generateRoleId('UNIQUE_ROLE') /** diff --git a/utils/json.ts b/utils/json.ts new file mode 100644 index 0000000..29b5247 --- /dev/null +++ b/utils/json.ts @@ -0,0 +1,13 @@ +import * as fs from 'fs' +import * as path from 'path' + +export function updateJsonFile(fileName: string, obj: any) { + const filePath = path.resolve(fileName) + console.log(filePath) + if (fs.existsSync(filePath)) { + const file = fs.readFileSync(filePath).toString() + let json = JSON.parse(file) + json = Object.assign(json, obj) + fs.writeFileSync(filePath, JSON.stringify(json, null, '\t')) + } +} diff --git a/utils/misc.ts b/utils/misc.ts new file mode 100644 index 0000000..d73f83c --- /dev/null +++ b/utils/misc.ts @@ -0,0 +1,95 @@ +import * as readline from 'readline' +/** + * List of colors to be used in the `print` function + */ +export const colors = { + // simple font colors + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + + // highlights + h_black: '\x1b[40m\x1b[37m', + h_red: '\x1b[41m\x1b[37m', + h_green: '\x1b[42m\x1b[30m', + h_yellow: '\x1b[43m\x1b[30m', + h_blue: '\x1b[44m\x1b[37m', + h_magenta: '\x1b[45m\x1b[37m', + h_cyan: '\x1b[46m\x1b[30m', + h_white: '\x1b[47m\x1b[30m', + + // aliases + highlight: '\x1b[47m\x1b[30m', // white bg and black font + error: '\x1b[41m\x1b[37m💥 ', // red bg, white font and explosion emoji + success: '\x1b[32m✅ ', // green font and check emoji + bigSuccess: '\x1b[42m\x1b[30m✅ ', // green bg, black font and check emoji + warn: '\x1b[43m\x1b[30m📣 ', // yellow bg, black font and megaphone emoji + wait: '\x1b[33m🕑 ', // yellow font and clock emoji + account: '\x1b[37m🐭 ', // white font and mouse face emoji + + // mandatory close + close: '\x1b[0m', +} + +/** + * Prints a colored message on your console/terminal + * @param {string} color Can be one of the above colors + * @param {string} message Whatever string + * @param {bool} breakLine Should it break line after the message? + * @example print(colors.green, "something"); + */ +export function print(color: string, message: string, breakLine = false) { + const lb = breakLine ? '\n' : '' + console.log(`${color}${message}${colors.close}${lb}`) +} + +// Expects the user to answer "yes". If they don't, the process is killed. +export async function confirmOrDie(query: string) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + print(colors.h_red, `✋ ${query}`) + + const answer = await new Promise(resolve => + rl.question('> [yes/no] ', ans => { + rl.close() + resolve(ans) + }), + ) + + if (answer !== 'yes') { + print(colors.warn, `Aborted by the operator.`) + process.exit(1) + } else { + print(colors.green, `Confirmed! Continuing...`) + } +} + +export async function yesOrNo(query: string) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }) + + print(colors.cyan, `✋ ${query}`) + + const answer = await new Promise(resolve => + rl.question('> [yes/no] ', ans => { + rl.close() + resolve(ans) + }), + ) + + if (answer !== 'yes') { + return false + } else { + return true + } +}