diff --git a/contracts/LiquidAccess.sol b/contracts/LiquidAccess.sol index 47a6a03..b478354 100644 --- a/contracts/LiquidAccess.sol +++ b/contracts/LiquidAccess.sol @@ -2,38 +2,61 @@ pragma solidity ^0.8.17; + +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/token/common/ERC2981.sol"; import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol"; -import "@openzeppelin/contracts/token/common/ERC2981.sol"; -import "@openzeppelin/contracts/access/Ownable.sol"; -import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; import "@openzeppelin/contracts/utils/Base64.sol"; +import "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol"; +import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/introspection/ERC165.sol"; + import "./interfaces/IERC4906.sol"; -contract LiquidAccess is ERC721, ERC721Enumerable, ERC2981, Ownable, IERC4906 { +contract LiquidAccess is ERC165, ERC721Burnable, ERC721Enumerable, ERC721URIStorage, ERC2981, IERC4906, AccessControl, EIP712 { using Strings for uint256; + /// @notice MAX_LOCKUP_PERIOD is hardcoded in the contract and can not be changed. + /// But owner can set _lockupPeriod, the actual value to something between + /// 0 and MAX_LOCKUP_PERIOD. uint256 public constant MAX_LOCKUP_PERIOD = 30 days; + uint256 private _lockupPeriod; // duration of lockup period in seconds + mapping(uint256 => uint256) private _lockups; // locked up until timestamp + string private _merchantName; // Merchant name uint256 private _merchantId; // Merchant id + string private _contractName; + string private _contractDescription; + string private _contractImage; + + uint256 private _tranferFromCounter; // TransferFrom counter - mapping(uint256 => string) private dateExpirations; // Mapping from token Id to date_expiration - mapping(uint256 => string) private typeSubscriptions; // Mapping from token Id to type_subscription + mapping(address => bool) private addressBlacklist; // Black list (user) mapping(uint256 => bool) private nftBlacklist; // Black list (nft) - mapping(uint256 => uint256) private _lockups; // tokenId => locked up until timestamp - uint256 private _lockupPeriod; // duration of lockup period in seconds - string private _nftName = "Genesis NFT pass"; - string private _nftDescription = "This pass gives you premium access to Aloha Browser. You may extend the expiration date by simply using the browser. Owners of the Genesis Pass will receive drops from future collaborations. Visit [alohaprofile.com](https://alohaprofile.com/) to activate your pass. Powered by [Liquid Access](https://liquidaccess.com/)."; - string private _nftImage = "https://storage.liquid-access.rocks/aloha-genesis-nft.png"; + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + uint256 public _nextTokenId; - string private _contractName = "Aloha Browser"; - string private _contractDescription = "Aloha Browser is a fast, free, full-featured web browser that provides maximum privacy and security."; - string private _contractImage = "https://storage.liquid-access.rocks/aloha.png"; + /// @dev For each user and each NFT we are storing what was the latest + // nonce. Then we are allowing to call permit() only for the most + // recent nonce. All of the previous nonces, even if not used, + // are void. On the other hand, adding NFT to this mapping allows + // having multiple permits active. + mapping(address => mapping(uint256 => uint256)) _permitNonces; + + // ============================================ + // Events section + // ============================================ event TransferFrom( address indexed from, @@ -72,7 +95,22 @@ contract LiquidAccess is ERC721, ERC721Enumerable, ERC2981, Ownable, IERC4906 { string indexed current ); + error AfterDeadline(uint256 providedDeadline, uint256 currentTime); + error ApproveToOwner(); + error HolderIsBlacklisted(address holder); + error NFTisBlacklisted(uint256 tokenId); + error NotOwner(address who, address expectedOwner, uint256 tokenId); + error PeriodTooLong(uint256 providedPeriod, uint256 allowedPeriod); + error RecipientIsBlacklisted(address recipient); error TokenIdNotFound(uint256 tokenId); + error TransferIsLocked(uint256 lockedUntil, uint256 currentTime); + error WrongInputs(); + error WrongNonce(uint256 providedNonce, uint256 currentNonce); + error WrongSigner(address expected, address actual); + + // ============================================ + // Modifiers section + // ============================================ modifier tokenExists(uint256 tokenId) { if (!_exists(tokenId)) { @@ -81,88 +119,55 @@ contract LiquidAccess is ERC721, ERC721Enumerable, ERC2981, Ownable, IERC4906 { _; } + /// @dev Important to have all families or our contract to have unique + /// names. Otherwise, permit could be re-used. constructor( string memory name_, string memory symbol_, string memory merchantName_, uint256 merchantId_ - ) ERC721(name_, symbol_) { + ) ERC721(name_, symbol_) + EIP712(name_, "1.0") { _merchantName = merchantName_; _merchantId = merchantId_; + _nextTokenId = 1; + _setDefaultRoyalty(msg.sender, 250); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); } + // ============================================ + // Views section + // ============================================ + function supportsInterface(bytes4 interfaceId) public view - override(ERC721, ERC721Enumerable, ERC2981, IERC165) + override(ERC721, ERC721Enumerable, ERC2981, ERC165, AccessControl) returns (bool) { return interfaceId == bytes4(0x49064906) || super.supportsInterface(interfaceId); } - /** - * @dev Sets royalty recipient and fee - */ - function setRoyalty(address _recipient, uint96 _royaltyFee) - external - onlyOwner + function tokenURI(uint256 tokenId) + public + view + tokenExists(tokenId) + override(ERC721, ERC721URIStorage) + returns (string memory) { - _setDefaultRoyalty(_recipient, _royaltyFee); - } - - function removeRoyalty() external onlyOwner { - _deleteDefaultRoyalty(); - } - - // safeMint ============================= - function safeMint( - address to, - string calldata subscriptionType, - string calldata expirationDate - ) external onlyOwner returns(uint256) { - uint256 tokenId = totalSupply() + 1; - typeSubscriptions[tokenId] = subscriptionType; - dateExpirations[tokenId] = expirationDate; - _safeMint(to, tokenId); - return tokenId; + return ERC721URIStorage.tokenURI(tokenId); } - // TransferFrom =================== - function _beforeTokenTransfer( - address from, - address to, - uint256 tokenId - ) internal virtual override(ERC721, ERC721Enumerable) { - super._beforeTokenTransfer(from, to, tokenId); - - // Transfer or burn - if (from != address(0)) { - require(!addressBlacklist[from], "LA: NFT Holder is blacklisted"); - } - - // Mint or transfer - if (to != address(0)) { - require(!addressBlacklist[to], "LA: Recipient is blacklisted"); - } - - // A transfer - if (from != address(0) && to != address(0)) { - require(!nftBlacklist[tokenId], "LA: NFT is blacklisted"); - - _requireUnlock(tokenId); - - _lockup(tokenId); - - _tranferFromCounter++; - emit TransferFrom(from, to, tokenId, _tranferFromCounter); - } - } - - // Lock-up period =================== - - function lockupLeftOf(uint256 tokenId) external view returns (uint256) { + /// @dev could be external but used in tests, thus public + function lockupLeftOf(uint256 tokenId) + public + view + tokenExists(tokenId) + returns (uint256) + { uint256 lockup = _lockups[tokenId]; if (lockup == 0 || block.timestamp >= lockup) { return 0; @@ -170,220 +175,321 @@ contract LiquidAccess is ERC721, ERC721Enumerable, ERC2981, Ownable, IERC4906 { return lockup - block.timestamp; } - function lockupPeriod() external view returns (uint256) { + /// @dev could be external but used in tests, thus public + function lockupPeriod() + public + view + returns (uint256) + { return _lockupPeriod; } - function setLockupPeriod(uint256 period) external onlyOwner { - require(period <= MAX_LOCKUP_PERIOD, "LA: period is too long"); - emit LockupPeriod(_lockupPeriod, period); - _lockupPeriod = period; + function isNFTBlacklisted(uint256 tokenId) + external + view + tokenExists(tokenId) + returns (bool) + { + return nftBlacklist[tokenId]; } - function _lockup(uint256 tokenId) private { - if (_lockupPeriod > 0) { - _lockups[tokenId] = block.timestamp + _lockupPeriod; - } + function isAddressBlacklisted(address _address) + external + view + returns (bool) + { + return addressBlacklist[_address]; } - function _requireUnlock(uint256 tokenId) private { - uint256 lockup = _lockups[tokenId]; - if (lockup != 0) { - require( - block.timestamp >= lockup, - "LA: Transfer is locked" - ); - - delete _lockups[tokenId]; - } + function merchantName() + external + view + returns (string memory) + { + return _merchantName; } - // NFT blacklist =================== - function addNFTToBlacklist(uint256 _nft) external onlyOwner tokenExists(_nft) { - nftBlacklist[_nft] = true; - emit NftBlacklist(_nft, true); + function merchantId() + external + view + returns (uint256) + { + return _merchantId; } - function removeNFTFromBlacklist(uint256 _nft) external onlyOwner { - delete nftBlacklist[_nft]; - emit NftBlacklist(_nft, false); + function userTokens(address user) + external + view + returns (uint256[] memory) + { + uint256 tokenCount = balanceOf(user); + uint256[] memory tokens = new uint256[](tokenCount); + for (uint256 i = 0; i < tokenCount; i++) { + tokens[i] = tokenOfOwnerByIndex(user, i); + } + return tokens; } - function isNFTBlacklisted(uint256 _nft) external view returns (bool) { - return nftBlacklist[_nft]; + function contractURI() external view returns (string memory) { + (address receiver, uint256 fee) = royaltyInfo(0, _feeDenominator()); + string memory receiverString = Strings.toHexString(receiver); + return + string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + bytes( + abi.encodePacked( + '{"name":"',_contractName,'",', + '"description":"',_contractDescription,'",', + '"image":"',_contractImage,'",', + '"seller_fee_basis_points":',fee.toString(),',', + '"fee_recipient":"',receiverString,'"', + '}' + ) + ) + ) + ) + ); } - // Users blacklist =================== - function addAddressToBlacklist(address _address) external onlyOwner { - addressBlacklist[_address] = true; - emit AddressBlacklist(_address, true); + // ============================================ + // Public section + // ============================================ + + /// @notice Everyone who posesses user's signature which satisfies to this signature, + /// can call this method to get a transfer approved. + /// @dev This is some deviation from EIP-2612 for NFTs. + function permit(address owner, address spender, uint256 tokenId, uint256 deadline, uint256 nonce, uint8 v, bytes32 r, bytes32 s) + external + { + bytes32 digest = _hashTypedDataV4(keccak256(abi.encode( + keccak256("permit(address owner,address spender,uint256 tokenId,uint256 deadline,uint256 nonce)"), + owner, + spender, + tokenId, + deadline, + nonce))); + address signer = ECDSA.recover(digest, v, r, s); + address tokenOwner = ERC721.ownerOf(tokenId); + if (owner != signer) revert WrongSigner(owner, signer); + if (tokenOwner != signer) revert NotOwner(signer, tokenOwner, tokenId); + if (block.timestamp > deadline) revert AfterDeadline(deadline, block.timestamp); + if (nonce != _permitNonces[owner][tokenId]) revert WrongNonce(nonce, _permitNonces[owner][tokenId]); + if (spender == owner) revert ApproveToOwner(); + + _approve(spender, tokenId); + ++_permitNonces[owner][tokenId]; + } + + // ============================================ + // Admin section + // ============================================ + + /// @notice Sets royalty recipient and fee + /// @param _recipient Who will receive royalty + /// @param _royaltyFee Nominator of royalty amount, assuming the default denominator of 10000. + function setRoyalty(address _recipient, uint96 _royaltyFee) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + _setDefaultRoyalty(_recipient, _royaltyFee); } - function removeAddressFromBlacklist(address _address) external onlyOwner { - delete addressBlacklist[_address]; - emit AddressBlacklist(_address, false); + /// @notice Effectively erases all information about royalties, so selling this NFT becomes free. + function removeRoyalty() + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + _deleteDefaultRoyalty(); } - function isAddressBlacklisted(address _address) external view returns (bool) { - return addressBlacklist[_address]; + /// @notice Generates a new NFT and places it to `to` account + function safeMint(address to, string calldata uri) + external + onlyRole(MINTER_ROLE) + returns(uint256) + { + uint256 tokenId = _nextTokenId; + _safeMint(to, tokenId); + _setTokenURI(tokenId, uri); + unchecked { + ++_nextTokenId; + } + return tokenId; } - function expirationDateOf(uint256 tokenId) - public - view - tokenExists(tokenId) - returns (string memory) + /// @notice Generates a series of new NFTs and places it to recipient's accounts + /// @dev Here we are using _mint, and not a _safeMint. Because _safeMint checks onERC721Received, + /// so the malitious user can place some contract which does some bad actions, + /// i.e. reverts and stops mint, adds theirs blacklisted addresses etc. + function batchMint( + address[] calldata recipients, + string[] calldata uris + ) + external + onlyRole(MINTER_ROLE) { - return dateExpirations[tokenId]; + if (recipients.length != uris.length) revert WrongInputs(); + + uint256 tokenId = _nextTokenId; + for (uint16 i = 0; i < recipients.length; ) { + if (!addressBlacklist[recipients[i]]) { + _mint(recipients[i], tokenId); + _setTokenURI(tokenId, uris[i]); + } + // Yes, there will be some gaps in tokenIds, when users are providing + // blacklisted addresses. This is needed because uris are representing + // already generated JSON's and assumes that all previous mints in a batch + // are successful. + unchecked { + ++i; + ++tokenId; + } + } + _nextTokenId = tokenId; } - function setExpirationDate(uint256 tokenId, string calldata expirationDate) + function changeTokenUri(uint256 tokenId, string calldata newUri) external - onlyOwner tokenExists(tokenId) + onlyRole(MINTER_ROLE) { - dateExpirations[tokenId] = expirationDate; - + _setTokenURI(tokenId, newUri); emit MetadataUpdate(tokenId); } - function subscriptionTypeOf(uint256 tokenId) + /// @notice Set a new lockup petiod. Existing lockups are not affected. + function setLockupPeriod(uint256 period) public - view - tokenExists(tokenId) - returns (string memory) + onlyRole(DEFAULT_ADMIN_ROLE) { - return typeSubscriptions[tokenId]; + if (period > MAX_LOCKUP_PERIOD) revert PeriodTooLong(period, MAX_LOCKUP_PERIOD); + emit LockupPeriod(_lockupPeriod, period); + _lockupPeriod = period; } - function setSubscriptionType(uint256 tokenId, string calldata subscriptionType) + function addNFTToBlacklist(uint256 _nft) external - onlyOwner - tokenExists(tokenId) + onlyRole(DEFAULT_ADMIN_ROLE) + tokenExists(_nft) { - typeSubscriptions[tokenId] = subscriptionType; - - emit MetadataUpdate(tokenId); + nftBlacklist[_nft] = true; + emit NftBlacklist(_nft, true); } - function merchantName() external view returns (string memory) { - return _merchantName; + function removeNFTFromBlacklist(uint256 _nft) + external + onlyRole(DEFAULT_ADMIN_ROLE) + tokenExists(_nft) + { + delete nftBlacklist[_nft]; + emit NftBlacklist(_nft, false); } - function merchantId() external view returns (uint256) { - return _merchantId; + function addAddressToBlacklist(address _address) + public + onlyRole(DEFAULT_ADMIN_ROLE) + { + addressBlacklist[_address] = true; + emit AddressBlacklist(_address, true); } - function userTokens(address owner) + function removeAddressFromBlacklist(address _address) external - view - returns (uint256[] memory) + onlyRole(DEFAULT_ADMIN_ROLE) { - uint256 tokenCount = balanceOf(owner); - uint256[] memory tokens = new uint256[](tokenCount); - for (uint256 i = 0; i < tokenCount; i++) { - tokens[i] = tokenOfOwnerByIndex(owner, i); - } - return tokens; + delete addressBlacklist[_address]; + emit AddressBlacklist(_address, false); } - // NFT metadata =================== - function updateAllTokensMetadata() private { - uint256 total = totalSupply(); - - if (total > 0) { - emit BatchMetadataUpdate(1, total); + /// @dev Emits update showing that metadata for all tokens was updated. + function updateAllTokensMetadata() + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + if (_nextTokenId > 1) { + emit BatchMetadataUpdate(1, _nextTokenId - 1); } } - function setNFTName(string calldata name) external onlyOwner { - _nftName = name; - - updateAllTokensMetadata(); - } - - function setNFTDescription(string calldata description) external onlyOwner { - _nftDescription = description; - - updateAllTokensMetadata(); - } - - function setNFTImage(string calldata image) external onlyOwner { - _nftImage = image; - - updateAllTokensMetadata(); - } - - function tokenURI(uint256 tokenId) - public - view - override - returns (string memory) + function setContractName(string calldata contractName_) + external + onlyRole(DEFAULT_ADMIN_ROLE) { - return - string( - abi.encodePacked( - "data:application/json;base64,", - Base64.encode( - bytes( - abi.encodePacked( - '{"name":"',_nftName,' #',tokenId.toString(),'",', - '"description":"',_nftDescription,'",', - '"image":"',_nftImage,'",', - '"attributes":[', - '{"trait_type":"Subscription Type","display_type":"string","value":"', - subscriptionTypeOf(tokenId), - '"},{"trait_type":"Expiration Date","display_type":"date","value":"', - expirationDateOf(tokenId), - '"}', - "]" - "}" - ) - ) - ) - ) - ); - } - - // Contract metadata =================== - function setContractName(string calldata name) external onlyOwner { - emit ContractName(_contractName, name); - _contractName = name; + emit ContractName(_contractName, contractName_); + _contractName = contractName_; } function setContractDescription(string calldata description) external - onlyOwner + onlyRole(DEFAULT_ADMIN_ROLE) { emit ContractDescription(_contractDescription, description); _contractDescription = description; } - function setContractImage(string calldata image) external onlyOwner { + function setContractImage(string calldata image) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { emit ContractImage(_contractImage, image); _contractImage = image; } - function contractURI() external view returns (string memory) { - (address receiver, uint256 fee) = royaltyInfo(0, _feeDenominator()); - string memory receiverString = Strings.toHexString(receiver); - return - string( - abi.encodePacked( - "data:application/json;base64,", - Base64.encode( - bytes( - abi.encodePacked( - '{"name":"',_contractName,'",', - '"description":"',_contractDescription,'",', - '"image":"',_contractImage,'",', - '"seller_fee_basis_points":',fee.toString(),',', - '"fee_recipient":"',receiverString,'"', - '}' - ) - ) - ) - ) - ); + // ============================================ + // Internals + // ============================================ + + function _beforeTokenTransfer( + address from, + address to, + uint256 tokenId + ) + internal + virtual + override(ERC721, ERC721Enumerable) + { + super._beforeTokenTransfer(from, to, tokenId); + + // Transfer or burn + if (from != address(0)) { + if (addressBlacklist[from]) revert HolderIsBlacklisted(from); + } + + // Mint or transfer + if (to != address(0)) { + if (addressBlacklist[to]) revert RecipientIsBlacklisted(to); + } + + // A transfer + if (from != address(0) && to != address(0)) { + if (nftBlacklist[tokenId]) revert NFTisBlacklisted(tokenId); + + uint256 lockup = _lockups[tokenId]; + if (lockup != 0 && block.timestamp < lockup) { + revert TransferIsLocked(lockup, block.timestamp); + } + + _lockup(tokenId); + + unchecked { ++_tranferFromCounter; } + emit TransferFrom(from, to, tokenId, _tranferFromCounter); + } + } + + function _lockup(uint256 tokenId) + private + { + if (_lockupPeriod > 0) { + _lockups[tokenId] = block.timestamp + _lockupPeriod; + } + } + + function _burn(uint256 tokenId) + internal + override(ERC721, ERC721URIStorage) + { + super._burn(tokenId); } } diff --git a/contracts/forTesting/EmptyContract.sol b/contracts/forTesting/EmptyContract.sol new file mode 100644 index 0000000..c6c0c23 --- /dev/null +++ b/contracts/forTesting/EmptyContract.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.9; + + +/// @dev This contract does not implement ERC721Receiver interface, +/// so it should be unable to receive NFTs. +contract EmptyContract {} \ No newline at end of file diff --git a/contracts/forTesting/MarketPlace.sol b/contracts/forTesting/MarketPlace.sol new file mode 100644 index 0000000..5ac4b79 --- /dev/null +++ b/contracts/forTesting/MarketPlace.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.9; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Burnable.sol"; + + +/// @dev Just a valid NFT receiver. +contract MarketPlace { + function submit(IERC721 collection, uint256 tokenId) external { + collection.safeTransferFrom(collection.ownerOf(tokenId), address(this), tokenId); + } + + function unmint(ERC721Burnable collection, uint256 tokenId) external { + collection.burn(tokenId); + } + + function onERC721Received( + address, /* operator */ + address, /* from */ + uint256, /* tokenId */ + bytes calldata /* data */ + ) external pure returns (bytes4) { + return this.onERC721Received.selector; + } +} diff --git a/contracts/interfaces/IERC4906.sol b/contracts/interfaces/IERC4906.sol index bd16b74..0b38c4f 100644 --- a/contracts/interfaces/IERC4906.sol +++ b/contracts/interfaces/IERC4906.sol @@ -2,11 +2,8 @@ pragma solidity ^0.8.17; -import "@openzeppelin/contracts/interfaces/IERC165.sol"; -import "@openzeppelin/contracts/interfaces/IERC721.sol"; - /// @title EIP-721 Metadata Update Extension -interface IERC4906 is IERC165, IERC721 { +interface IERC4906 { /// @dev This event emits when the metadata of a token is changed. /// So that the third-party platforms such as NFT market could /// timely update the images and related attributes of the NFT. diff --git a/test/LiquidAccess.test.js b/test/LiquidAccess.test.js index fa1b242..9917801 100644 --- a/test/LiquidAccess.test.js +++ b/test/LiquidAccess.test.js @@ -1,7 +1,8 @@ const { ethers } = require("hardhat"); -const { expect } = require("chai"); +const { expect, assert } = require("chai"); const { AddressZero } = ethers.constants; const { time, BN } = require("@openzeppelin/test-helpers"); +const util = require('util') const expectRevert = async (statement, reason) => { await expect(statement).to.be.revertedWith(reason); @@ -12,19 +13,24 @@ const expectRevertCustom = async (contract, statement, reason) => { } describe("Contract: LiquidAccess", () => { - let owner, wallet1, wallet2, wallet3; + let owner, wallet1, wallet2, wallet3, minter; let liquidAccess; - let mint; + let mint, batchMint; + let LiquidAccess; before(async () => { - [owner, wallet1, wallet2, wallet3] = await ethers.getSigners(); + [owner, wallet1, wallet2, wallet3, minter] = await ethers.getSigners(); }); beforeEach(async () => { - const LiquidAccess = await ethers.getContractFactory("LiquidAccess"); + LiquidAccess = await ethers.getContractFactory("LiquidAccess"); liquidAccess = await LiquidAccess.deploy("LiquidAccess", "LQD", "Merchant", 42); - mint = async (subcriptionType = '', expirationDate = '') => - liquidAccess.safeMint(owner.address, subcriptionType, expirationDate); + mint = (uri = 'ipfs://S9332fa/some') => liquidAccess.connect(minter).safeMint(owner.address, uri); + batchMint = (recipients = [owner, wallet1, wallet2, owner, wallet3].map(s => s.address), + uris = [1, 2, 3, 4, 5].map(i => `ipfs://S9332fa/${i}`)) => + liquidAccess.connect(minter).batchMint(recipients, uris); + const assignMinterRoleTx = await liquidAccess.grantRole(await liquidAccess.MINTER_ROLE(), minter.address) + await assignMinterRoleTx.wait() }); describe("Contract info", () => { @@ -53,7 +59,7 @@ describe("Contract: LiquidAccess", () => { expect(await liquidAccess.balanceOf(owner.address)).to.equal(1); }); - it("shoud return correct tokenId", async () => { + it("should return correct tokenId", async () => { for (let i = 1; i < 10; i++) { const tx = await mint(); const receipt = await tx.wait(); @@ -69,73 +75,289 @@ describe("Contract: LiquidAccess", () => { it("should revert if not owner", async () => { await expectRevert( - liquidAccess.connect(wallet1).safeMint(owner.address, '', ''), - "Ownable: caller is not the owner" + liquidAccess.connect(wallet1).safeMint(owner.address, ''), + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); await expectRevert( - liquidAccess.connect(wallet1).safeMint(wallet1.address, '', ''), - "Ownable: caller is not the owner" + liquidAccess.connect(wallet1).safeMint(wallet1.address, ''), + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); }); - }); - describe("Token info", async () => { - const subcriptionType = "SuperPuper"; - const expirationDate = "Tomorrow"; + it("should not mint to emptry contract", async () => { + const EmptyContract = await ethers.getContractFactory("EmptyContract"); + const emptyContract = await EmptyContract.deploy(); + await expectRevert( + liquidAccess.connect(minter).safeMint(emptyContract.address, ''), + "ERC721: transfer to non ERC721Receiver implementer" + ); + }) + }); - it("should have the correct subscription type", async () => { - await mint(subcriptionType, ''); - expect(await liquidAccess.subscriptionTypeOf(1)).to.equal(subcriptionType); + describe("Token Burn", async () => { + it("should be able to burn existing token", async () => { + const minttx = await mint(); + const minteffects = await minttx.wait() + expect(await liquidAccess.balanceOf(owner.address)).to.equal(1); + const tx = await liquidAccess.burn(1); + await tx.wait() + expect(await liquidAccess.balanceOf(owner.address)).to.equal(0); + await expectRevert( + liquidAccess.ownerOf(1), + "ERC721: invalid token ID" + ) }); - it("should be able to change subscription type", async () => { - await mint(subcriptionType, ''); - const newSubcriptionType = "SuperPuperDuper"; - await liquidAccess.setSubscriptionType(1, newSubcriptionType); - expect(await liquidAccess.subscriptionTypeOf(1)).to.equal(newSubcriptionType); + it("fails burning non existing token", async () => { + await expectRevert( + liquidAccess.burn(100), + "ERC721: invalid token ID" + ) }); - it("should have the correct expiration date", async () => { - await mint('', expirationDate); - expect(await liquidAccess.expirationDateOf(1)).to.equal(expirationDate); - }); + it("Allows for the side contract to burn a token", async () => { + const MarketPlace = await ethers.getContractFactory("MarketPlace") + const marketPlace = await MarketPlace.deploy() + const burntx = await mint() + expect (await liquidAccess.totalSupply()).to.be.eq(1) - it("should be able to change expiration date", async () => { - await mint('', expirationDate); - const newExpirationDate = "AfterTomorrow"; - await liquidAccess.setExpirationDate(1, newExpirationDate); - expect(await liquidAccess.expirationDateOf(1)).to.equal(newExpirationDate); - }); + const approveTx = await liquidAccess.approve(marketPlace.address, 1) + await marketPlace.unmint(liquidAccess.address, 1) + expect (await liquidAccess.totalSupply()).to.be.eq(0) + }) + }) - it("should revert if token does not exist", async () => { - await expectRevertCustom( - liquidAccess, - liquidAccess.subscriptionTypeOf(1), - "TokenIdNotFound" + describe("Batch minting", async () => { + const checkAmounts = async (amounts) => { + for (const address of Object.keys(amounts)) { + const amount = await liquidAccess.balanceOf(address); + expect(amount).to.be.eq(amounts[address]); + } + } + + const extractIdsFromReciept = async (recieptFut) => { + const tokenIds = (await recieptFut).events.filter(e => e.event === 'Transfer').map(e => e.args[2]); + tokenIds.sort((a, b) => a - b); + return tokenIds; + } + + it("should deliver NFTs to recipients", async () => { + await batchMint(); + await checkAmounts({ + [owner.address]: 2, + [wallet1.address]: 1, + [wallet2.address]: 1, + [wallet3.address]: 1, + }) + }) + + it("should continue enumeration", async () => { + const tx1 = await batchMint(); + expect(await extractIdsFromReciept(tx1.wait())).to.be.deep.eq([1,2,3,4,5]); + + const tx2 = await batchMint(); + expect(await extractIdsFromReciept(tx2.wait())).to.be.deep.eq([6,7,8,9,10]); + + expect(await liquidAccess.totalSupply()).to.be.eq(10) + }) + + it("should not allow owner to mint", async () => { + await expectRevert( + liquidAccess.connect(owner).batchMint([owner.address], ['ipfs://something']), + `AccessControl: account ${owner.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); - await expectRevertCustom( - liquidAccess, - liquidAccess.expirationDateOf(1), - "TokenIdNotFound" + await expectRevert( + liquidAccess.connect(wallet2).batchMint([wallet2.address], ['ipfs://something']), + `AccessControl: account ${wallet2.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); - }); - }); + }) + + it("should not mint to blacklisted users", async () => { + await liquidAccess.addAddressToBlacklist(wallet2.address); + const tx = await batchMint(); + expect(await extractIdsFromReciept(tx.wait())).to.be.deep.eq([1,2,4,5]); + await checkAmounts({ + [owner.address]: 2, + [wallet1.address]: 1, + [wallet2.address]: 0, + [wallet3.address]: 1, + }) + + expect(await liquidAccess.totalSupply()).to.be.eq(4); + }) + + it("no error when minting to non ERC721Receiver contracts (unfortunately)", async () => { + const EmptyContract = await ethers.getContractFactory("EmptyContract"); + const emptyContract = await EmptyContract.deploy(); + + const tx = await batchMint([owner.address, emptyContract.address], ["ipfs://1", "ipfs://2"]); + expect(await extractIdsFromReciept(tx.wait())).to.be.deep.eq([1,2]); + await checkAmounts({ + [owner.address]: 1, + [emptyContract.address]: 1, + }) + }) + }) + + describe("ERC2612: Permit", async () => { + let marketPlace; + let signAndPrepareTx; + let domainData; + + const permitType = [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "tokenId", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "nonce", type: "uint256" }, + ]; + + before(async() => { + const MarketPlace = await ethers.getContractFactory("MarketPlace"); + marketPlace = await MarketPlace.deploy(); + }) + + beforeEach(async() => { + await batchMint(); + + domainData = { + name: await liquidAccess.name(), + version: "1.0", + chainId: "31337", + verifyingContract: liquidAccess.address, + }; + + signAndPrepareTx = async (signer, permitData) => { + const signature = (await signer._signTypedData( + domainData, {permit: permitType}, permitData + )).substring(2); + const r = "0x" + signature.substring(0, 64); + const s = "0x" + signature.substring(64, 128); + const v = parseInt(signature.substring(128, 130), 16); + + return liquidAccess.connect(wallet3).permit( + permitData.owner, permitData.spender, permitData.tokenId, permitData.deadline, permitData.nonce, + v, r, s); + } + }) + + it("should not allow to transfer to marketplace without permission", async () => { + // This test mostly to check that permissions system hasn't changed to whitelist MarketPlace, + // because if is it whitelisted by approveForAll or something of that sort, tests below are not + // testing anything. + await expectRevert( + marketPlace.submit(liquidAccess.address, 1), + "ERC721: caller is not token owner nor approved" + ); + }) + + it("should check the permission", async () => { + const permitFrom = wallet1; + const timeStamp = (await ethers.provider.getBlock("latest")).timestamp + const permitData = { + owner: permitFrom.address, + spender: marketPlace.address, + tokenId: 1, // Belongs to owner, not to wallet1. + deadline: timeStamp + 60, + nonce: 0, + }; + const op = signAndPrepareTx(permitFrom, permitData); + await expectRevertCustom(LiquidAccess, op, "NotOwner"); + }) + + it("should check the nonce, not allowing to re-use same signature", async () => { + const permitFrom = wallet1; + const usersToken = (await liquidAccess.userTokens(permitFrom.address))[0]; + const timeStamp = (await ethers.provider.getBlock("latest")).timestamp + const permitData = { + owner: permitFrom.address, + spender: marketPlace.address, + tokenId: usersToken, + deadline: timeStamp + 60, + nonce: 0, + }; + const signature = (await permitFrom._signTypedData( + domainData, {permit: permitType}, permitData + )).substring(2); + const r = "0x" + signature.substring(0, 64); + const s = "0x" + signature.substring(64, 128); + const v = parseInt(signature.substring(128, 130), 16); + + // First attempt should be OK. + await liquidAccess.connect(wallet3).permit( + permitData.owner, permitData.spender, permitData.tokenId, permitData.deadline, permitData.nonce, + v, r, s); + + // Second attempt not OK. + await expectRevertCustom( + LiquidAccess, + liquidAccess.connect(wallet3).permit( + permitData.owner, permitData.spender, permitData.tokenId, permitData.deadline, permitData.nonce, + v, r, s), + "WrongNonce"); + + // But after updating nonce should be OK. + permitData.nonce = 1; + + const signature2 = (await permitFrom._signTypedData( + domainData, {permit: permitType}, permitData + )).substring(2); + const r2 = "0x" + signature2.substring(0, 64); + const s2 = "0x" + signature2.substring(64, 128); + const v2 = parseInt(signature2.substring(128, 130), 16); + + await liquidAccess.connect(wallet3).permit( + permitData.owner, permitData.spender, permitData.tokenId, permitData.deadline, permitData.nonce, + v2, r2, s2); + }) + + it("should check the deadline", async () => { + const permitFrom = wallet1; + const usersToken = (await liquidAccess.userTokens(permitFrom.address))[0]; + const timeStamp = (await ethers.provider.getBlock("latest")).timestamp + const permitData = { + owner: permitFrom.address, + spender: marketPlace.address, + tokenId: usersToken, + deadline: timeStamp - 60, + nonce: 0, + }; + const op = signAndPrepareTx(permitFrom, permitData); + await expectRevertCustom(LiquidAccess, op, "AfterDeadline"); + }) + + it("when signature is OK, permission works", async () => { + const permitFrom = wallet1; + const usersToken = (await liquidAccess.userTokens(permitFrom.address))[0]; + const timeStamp = (await ethers.provider.getBlock("latest")).timestamp + const permitData = { + owner: permitFrom.address, + spender: marketPlace.address, + tokenId: usersToken, + deadline: timeStamp + 60, + nonce: 0, + }; + const op = signAndPrepareTx(permitFrom, permitData); + await op; + await marketPlace.submit(liquidAccess.address, permitData.tokenId); + }) + }) describe("Transfer", async () => { it("should emit TransferFrom event with transfer counter", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expect(liquidAccess.transferFrom(owner.address, wallet1.address, 1)) .to.emit(liquidAccess, "TransferFrom") .withArgs(owner.address, wallet1.address, 1, 1); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expect(liquidAccess.transferFrom(owner.address, wallet1.address, 2)) .to.emit(liquidAccess, "TransferFrom") .withArgs(owner.address, wallet1.address, 2, 2); }); it("should revert if not token owner", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expectRevert( liquidAccess.connect(wallet1).transferFrom(owner.address, wallet1.address, 1), "ERC721: caller is not token owner nor approved" @@ -145,19 +367,19 @@ describe("Contract: LiquidAccess", () => { describe("SafeTransfer", async () => { it("should emit TransferFrom event with transfer counter", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expect(liquidAccess["safeTransferFrom(address,address,uint256)"](owner.address, wallet1.address, 1)) .to.emit(liquidAccess, "TransferFrom") .withArgs(owner.address, wallet1.address, 1, 1); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expect(liquidAccess["safeTransferFrom(address,address,uint256)"](owner.address, wallet1.address, 2)) .to.emit(liquidAccess, "TransferFrom") .withArgs(owner.address, wallet1.address, 2, 2); }); it("should revert if not token owner", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await expectRevert( liquidAccess.connect(wallet1)["safeTransferFrom(address,address,uint256)"](owner.address, wallet1.address, 1), "ERC721: caller is not token owner nor approved" @@ -167,7 +389,7 @@ describe("Contract: LiquidAccess", () => { describe("Approved transfer", async () => { it("should be able to approve an address for a transfer", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.setApprovalForAll(wallet1.address, true); expect(await liquidAccess.isApprovedForAll(owner.address, wallet1.address)).to.be.true; }); @@ -190,31 +412,33 @@ describe("Contract: LiquidAccess", () => { }); it("should revert if lockup is greater than 30 days", async () => { - await expectRevert( + await expectRevertCustom( + LiquidAccess, liquidAccess.setLockupPeriod(31 * 24 * 60 * 60), - "LA: period is too long" + "PeriodTooLong" ); }); it("should revert if not owner", async () => { await expectRevert( liquidAccess.connect(wallet1).setLockupPeriod(100), - "Ownable: caller is not the owner" + "AccessControl: account 0x70997970c51812dc3a010c7d01b50e0d17dc79c8 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" ); }); it("should lock transfers after each transfer", async () => { await liquidAccess.setLockupPeriod(60); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.transferFrom(owner.address, wallet1.address, 1); - await expectRevert( + await expectRevertCustom( + LiquidAccess, liquidAccess.connect(wallet1).transferFrom(wallet1.address, wallet2.address, 1), - "LA: Transfer is locked" + "TransferIsLocked" ); }); it("should be able to retrieve lockup period of a token", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); expect(await liquidAccess.lockupLeftOf(1)).to.equal(0); await liquidAccess.setLockupPeriod(60); await liquidAccess.transferFrom(owner.address, wallet1.address, 1); @@ -232,7 +456,7 @@ describe("Contract: LiquidAccess", () => { it("should unlock transfers after lockup period", async () => { await liquidAccess.setLockupPeriod(60); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.transferFrom(owner.address, wallet1.address, 1); await ethers.provider.send("evm_increaseTime", [60]); await ethers.provider.send("evm_mine"); @@ -241,10 +465,16 @@ describe("Contract: LiquidAccess", () => { it("should not revert if lockup period is 0", async () => { await liquidAccess.setLockupPeriod(0); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.transferFrom(owner.address, wallet1.address, 1); await liquidAccess.connect(wallet1).transferFrom(wallet1.address, wallet2.address, 1); }); + + it("should not be locked just after mint", async () => { + await liquidAccess.setLockupPeriod(30); + await mint(); + await liquidAccess.transferFrom(owner.address, wallet2.address, 1); + }) }); @@ -278,22 +508,22 @@ describe("Contract: LiquidAccess", () => { it("should revert if caller is not owner", async () => { await expectRevert( liquidAccess.connect(wallet1).setRoyalty(wallet1.address, 0), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); await expectRevert( liquidAccess.connect(wallet1).removeRoyalty(), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); }); }); describe("NFT blacklisting", async () => { it("should be able to blacklist NFT", async () => { - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); + await mint(); + await mint(); + await mint(); await liquidAccess.addNFTToBlacklist(1); await liquidAccess.addNFTToBlacklist(3); @@ -305,10 +535,10 @@ describe("Contract: LiquidAccess", () => { }); it("should be able to remove NFT from blacklist", async () => { - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); + await mint(); + await mint(); + await mint(); await liquidAccess.addNFTToBlacklist(1); await liquidAccess.addNFTToBlacklist(3); @@ -325,31 +555,34 @@ describe("Contract: LiquidAccess", () => { it("should revert if caller is not owner", async () => { await expectRevert( liquidAccess.connect(wallet1).addNFTToBlacklist(1), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` + ); await expectRevert( liquidAccess.connect(wallet1).removeNFTFromBlacklist(1), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` + ); }); it("should not be able to transfer blacklisted NFT", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.addNFTToBlacklist(1); - await expectRevert( + await expectRevertCustom( + LiquidAccess, liquidAccess.transferFrom(owner.address, wallet1.address, 1), - "LA: NFT is blacklisted" + "NFTisBlacklisted" ); }); }); describe("Address blacklisting", async () => { it("should be able to blacklist address", async () => { - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); + await mint(); + await mint(); + await mint(); await liquidAccess.addAddressToBlacklist(wallet1.address); await liquidAccess.addAddressToBlacklist(wallet2.address); @@ -360,10 +593,10 @@ describe("Contract: LiquidAccess", () => { }); it("should be able to remove address from blacklist", async () => { - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); + await mint(); + await mint(); + await mint(); await liquidAccess.addAddressToBlacklist(wallet1.address); await liquidAccess.addAddressToBlacklist(wallet2.address); @@ -379,41 +612,42 @@ describe("Contract: LiquidAccess", () => { it("should revert if caller is not owner", async () => { await expectRevert( liquidAccess.connect(wallet1).addAddressToBlacklist(wallet1.address), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); await expectRevert( liquidAccess.connect(wallet1).removeAddressFromBlacklist(wallet1.address), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); }); it("should not be able to transfer NFT to blacklisted address", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.addAddressToBlacklist(wallet1.address); - await expectRevert( + await expectRevertCustom( + LiquidAccess, liquidAccess.transferFrom(owner.address, wallet1.address, 1), - "LA: Recipient is blacklisted" + "RecipientIsBlacklisted" ); }); it("should not be able to transfer NFT from blacklisted address", async () => { - await liquidAccess.safeMint(owner.address, '', ''); + await mint(); await liquidAccess.addAddressToBlacklist(owner.address); - await expectRevert( + await expectRevertCustom( + LiquidAccess, liquidAccess.transferFrom(owner.address, wallet1.address, 1), - "LA: NFT Holder is blacklisted" + "HolderIsBlacklisted" ); }); }); describe("User tokens", async () => { it("should be able to retrieve user tokens", async () => { - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); - await liquidAccess.safeMint(owner.address, '', ''); + for (let i = 0; i < 4; ++i) { + await mint(); + } await liquidAccess.transferFrom(owner.address, wallet1.address, 1); await liquidAccess.transferFrom(owner.address, wallet1.address, 3); @@ -424,72 +658,35 @@ describe("Contract: LiquidAccess", () => { }); describe("Metadata", async () => { - async function getMetadata(id) { - const tokenURI = await liquidAccess.tokenURI(1); - // strip off the first 29 characters - const base64 = tokenURI.slice(29); - const metadata = Buffer.from(base64, 'base64').toString('utf-8'); - return JSON.parse(metadata.toString()); - } - it("should be able to change NFT meta name", async () => { - await mint(); - const name = "Liquid Access Pass"; - await liquidAccess.setNFTName(name); - - const metadata = await getMetadata(1); - expect(metadata.name).to.equal(name + " #1"); - }); - - it("should be able to change NFT meta description", async () => { - await mint(); - const description = "Liquid Access Pass"; - await liquidAccess.setNFTDescription(description); + beforeEach(async () => { + await mint('ipfs://some-uri-assigned'); + }) - const metadata = await getMetadata(1); - expect(metadata.description).to.equal(description); + it("should have assigned URI after minting", async () => { + const uri = await liquidAccess.tokenURI(1) + expect(uri).to.be.eq('ipfs://some-uri-assigned') }); - it("should be able to change NFT meta image", async () => { - await mint(); - const image = "https://la-sc-test.io/logo.png"; - await liquidAccess.setNFTImage(image); - - const metadata = await getMetadata(1); - expect(metadata.image).to.equal(image); - }); + it("should be able to change NFT URI", async () => { + const changeURItx = await liquidAccess.connect(minter).changeTokenUri(1, 'ipfs://newAddress') + const receipt = await changeURItx.wait() - it("should have correct NFT meta attributes", async () => { - const subcriptionType = 'abc'; - const expirationDate = 'xyz'; - await mint(subcriptionType, expirationDate); + const updates = receipt.events.filter(e => e.event === 'MetadataUpdate') + expect(updates).to.have.length(1); + expect(updates[0].args[0]).to.be.eq(1) - const metadata = await getMetadata(1); - expect(metadata.attributes).to.deep.equal([ - { - "trait_type": "Subscription Type", - "display_type": "string", - "value": subcriptionType - }, - { - "trait_type": "Expiration Date", - "display_type": "date", - "value": expirationDate - } - ]); + const uri = await liquidAccess.tokenURI(1) + expect(uri).to.be.eq('ipfs://newAddress') }); it("should revert if caller is not owner", async () => { await expectRevert( - liquidAccess.connect(wallet1).setNFTName(""), - "Ownable: caller is not the owner" + liquidAccess.connect(wallet1).changeTokenUri(1, 'ipfs://wrong-address'), + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); await expectRevert( - liquidAccess.connect(wallet1).setNFTDescription(""), - "Ownable: caller is not the owner" - ); - await expectRevert( - liquidAccess.connect(wallet1).setNFTImage(""), - "Ownable: caller is not the owner" + liquidAccess.connect(owner).changeTokenUri(1, 'ipfs://wrong-address'), + `AccessControl: account ${owner.address.toLowerCase()} is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6` ); }); }); @@ -536,16 +733,15 @@ describe("Contract: LiquidAccess", () => { it("should revert if caller is not owner", async () => { await expectRevert( liquidAccess.connect(wallet1).setContractName(""), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); await expectRevert( liquidAccess.connect(wallet1).setContractDescription(""), - "Ownable: caller is not the owner" + `AccessControl: account ${wallet1.address.toLowerCase()} is missing role 0x0000000000000000000000000000000000000000000000000000000000000000` ); }); }); - describe("Interface support", () => { @@ -569,4 +765,4 @@ describe("Contract: LiquidAccess", () => { expect(await liquidAccess.supportsInterface("0x2a55205a")).to.be.true; }); }) -}); \ No newline at end of file +});