From 7472b4564b7c7af4e2060183780ce717b60910ec Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Mon, 18 Apr 2022 20:02:50 -0500 Subject: [PATCH 1/6] chore: [#1] update changeme --- README.md | 2 +- package-lock.json | 88 +++++++++++++++++++++++++++++++++++++++++++++-- package.json | 11 +++--- 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b8025aa..3d526d6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CHANGEME +# Parcel Contracts ## Setup diff --git a/package-lock.json b/package-lock.json index 629bce0..91336fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "CHANGEME", + "name": "@citydao/parcel-contracts", "version": "0.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "CHANGEME", + "name": "@citydao/parcel-contracts", "version": "0.0.0", "license": "UNLICENSED", "dependencies": { @@ -32,6 +32,7 @@ "ethereum-waffle": "3.4.4", "hardhat": "2.9.3", "husky": "7.0.4", + "merkletreejs": "0.2.31", "mocha": "9.2.2", "prettier": "2.6.2", "prettier-plugin-solidity": "1.0.0-beta.19", @@ -2701,6 +2702,15 @@ "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" }, + "node_modules/bignumber.js": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", + "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -2830,6 +2840,12 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/buffer-reverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz", + "integrity": "sha1-SSg8jvpvkBvAH6MwTQYCeXGuL2A=", + "dev": true + }, "node_modules/buffer-xor": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-2.0.2.tgz", @@ -4058,6 +4074,12 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", + "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==", + "dev": true + }, "node_modules/cz-conventional-changelog": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", @@ -17168,6 +17190,22 @@ "semaphore-async-await": "^1.5.1" } }, + "node_modules/merkletreejs": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.2.31.tgz", + "integrity": "sha512-dnK2sE43OebmMe5Qnq1wXvvMIjZjm1u6CcB2KeW6cghlN4p21OpCUr2p56KTVf20KJItNChVsGnimcscp9f+yw==", + "dev": true, + "dependencies": { + "bignumber.js": "^9.0.1", + "buffer-reverse": "^1.0.1", + "crypto-js": "^3.1.9-1", + "treeify": "^1.1.0", + "web3-utils": "^1.3.4" + }, + "engines": { + "node": ">= 7.6.0" + } + }, "node_modules/micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -20420,6 +20458,15 @@ "node": ">=0.6" } }, + "node_modules/treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true, + "engines": { + "node": ">=0.6" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", @@ -23328,6 +23375,12 @@ "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" }, + "bignumber.js": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.2.tgz", + "integrity": "sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==", + "dev": true + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -23439,6 +23492,12 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "buffer-reverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz", + "integrity": "sha1-SSg8jvpvkBvAH6MwTQYCeXGuL2A=", + "dev": true + }, "buffer-xor": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-2.0.2.tgz", @@ -24411,6 +24470,12 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", + "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==", + "dev": true + }, "cz-conventional-changelog": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.3.0.tgz", @@ -34527,6 +34592,19 @@ "semaphore-async-await": "^1.5.1" } }, + "merkletreejs": { + "version": "0.2.31", + "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.2.31.tgz", + "integrity": "sha512-dnK2sE43OebmMe5Qnq1wXvvMIjZjm1u6CcB2KeW6cghlN4p21OpCUr2p56KTVf20KJItNChVsGnimcscp9f+yw==", + "dev": true, + "requires": { + "bignumber.js": "^9.0.1", + "buffer-reverse": "^1.0.1", + "crypto-js": "^3.1.9-1", + "treeify": "^1.1.0", + "web3-utils": "^1.3.4" + } + }, "micromatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.4.tgz", @@ -36997,6 +37075,12 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "treeify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz", + "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==", + "dev": true + }, "trim-newlines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", diff --git a/package.json b/package.json index 76cc0c0..dad2432 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "author": "CityDAO", - "name": "CHANGEME", + "name": "@citydao/parcel-contracts", "description": "", "private": true, "license": "UNLICENSED", "version": "0.0.0", - "homepage": "https://github.com/citydaoproject/CHANGEME/wiki/Home", + "homepage": "https://github.com/citydaoproject/parcel-contracts/wiki/Home", "repository": { "type": "git", - "url": "https://github.com/citydaoproject/CHANGEME" + "url": "https://github.com/citydaoproject/parcel-contracts" }, "bugs": { - "url": "https://github.com/citydaoproject/CHANGEME/issues" + "url": "https://github.com/citydaoproject/parcel-contracts/issues" }, "files": [ "artifacts", @@ -65,6 +65,7 @@ "ethereum-waffle": "3.4.4", "hardhat": "2.9.3", "husky": "7.0.4", + "merkletreejs": "0.2.31", "mocha": "9.2.2", "prettier": "2.6.2", "prettier-plugin-solidity": "1.0.0-beta.19", @@ -88,7 +89,7 @@ } }, "standard-version": { - "issueUrlFormat": "https://github.com/citydaoproject/CHANGEME/issues/{{id}}", + "issueUrlFormat": "https://github.com/citydaoproject/parcel-contracts/issues/{{id}}", "issuePrefixes": [ "#" ], From cf389f46ef6cbed4a90af9b44356a0dfc51738a1 Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Wed, 20 Apr 2022 20:13:22 -0500 Subject: [PATCH 2/6] chore: [#1] beginning to ParcelNFT --- contracts/ParcelNFT.sol | 26 ++++++++++++++++ contracts/Roles.sol | 8 +++++ package.json | 2 +- src/constants/accounts.ts | 9 ++++++ src/constants/roles.ts | 10 +++++++ src/utils/fixedBytes.ts | 8 +++++ test/contracts/access/access.spec.ts | 44 ++++++++++++++++++++++++++++ test/helpers/ParcelNFTHelper.ts | 11 +++++++ test/helpers/Roles.ts | 5 ++++ 9 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 contracts/ParcelNFT.sol create mode 100644 contracts/Roles.sol create mode 100644 src/constants/accounts.ts create mode 100644 src/constants/roles.ts create mode 100644 src/utils/fixedBytes.ts create mode 100644 test/contracts/access/access.spec.ts create mode 100644 test/helpers/ParcelNFTHelper.ts create mode 100644 test/helpers/Roles.ts diff --git a/contracts/ParcelNFT.sol b/contracts/ParcelNFT.sol new file mode 100644 index 0000000..6419790 --- /dev/null +++ b/contracts/ParcelNFT.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.9; + +import '@gnus.ai/contracts-upgradeable-diamond/access/AccessControlUpgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/ERC721Upgradeable.sol'; +import './Roles.sol'; + +contract ParcelNFT is AccessControlUpgradeable, ERC721Upgradeable { + function initialize(address superAdmin) public initializer { + if (superAdmin == address(0)) { + superAdmin = _msgSender(); + } + _setupRole(Roles.SUPER_ADMIN_ROLE, superAdmin); + } + + function supportsInterface(bytes4 interfaceId) + public + view + virtual + override(AccessControlUpgradeable, ERC721Upgradeable) + returns (bool) + { + return super.supportsInterface(interfaceId); + } +} diff --git a/contracts/Roles.sol b/contracts/Roles.sol new file mode 100644 index 0000000..f22d024 --- /dev/null +++ b/contracts/Roles.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.9; + +library Roles { + bytes32 public constant SUPER_ADMIN_ROLE = 0x00; + bytes32 public constant ADMIN_ROLE = keccak256('citydao.Admin'); +} diff --git a/package.json b/package.json index dad2432..e2c0d0b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "build:watch": "tsc --watch", "copy-declarations": "cp -R types dist", "rebuild": "npm run clean && npm run build", - "test": "ts-mocha", + "test": "hardhat test", "posttest": "npm run lint", "lint": "solhint 'contracts/**/*.sol' && eslint", "pretty": "pretty-quick", diff --git a/src/constants/accounts.ts b/src/constants/accounts.ts new file mode 100644 index 0000000..21da09f --- /dev/null +++ b/src/constants/accounts.ts @@ -0,0 +1,9 @@ +import { toFixedByteString } from '../utils/fixedBytes'; + +export type AccountAddress = string; +export type ContractAddress = string; +export type EthereumAddress = AccountAddress | ContractAddress; + +export const toEthereumAddress = (value: number): EthereumAddress => toFixedByteString(value, 20); + +export const ZERO_ADDRESS = toEthereumAddress(0); diff --git a/src/constants/roles.ts b/src/constants/roles.ts new file mode 100644 index 0000000..bc01139 --- /dev/null +++ b/src/constants/roles.ts @@ -0,0 +1,10 @@ +import { BytesLike } from 'ethers'; +import { Hexable, keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { toByte32String } from '../utils/fixedBytes'; + +export type AccessRole = string; + +export const toAccessRole = (value: BytesLike | Hexable | number): AccessRole => toByte32String(value); + +export const SUPER_ADMIN_ROLE = toAccessRole(0); +export const ADMIN_ROLE = keccak256(toUtf8Bytes('citydao.Admin')); diff --git a/src/utils/fixedBytes.ts b/src/utils/fixedBytes.ts new file mode 100644 index 0000000..592e187 --- /dev/null +++ b/src/utils/fixedBytes.ts @@ -0,0 +1,8 @@ +import { BytesLike } from 'ethers'; +import { Hexable, hexlify, zeroPad } from 'ethers/lib/utils'; + +export const toFixedByteString = (value: BytesLike | Hexable | number, size: number): string => + hexlify(zeroPad(hexlify(value), size)); + +export const toByte4String = (value: BytesLike | Hexable | number) => toFixedByteString(value, 4); +export const toByte32String = (value: BytesLike | Hexable | number) => toFixedByteString(value, 32); diff --git a/test/contracts/access/access.spec.ts b/test/contracts/access/access.spec.ts new file mode 100644 index 0000000..6b815ee --- /dev/null +++ b/test/contracts/access/access.spec.ts @@ -0,0 +1,44 @@ +import { expect } from 'chai'; +import { SUPER_ADMIN_ROLE } from '../../../dist/constants/roles'; +import { ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; +import { createParcelNFT } from '../../helpers/ParcelNFTHelper'; +import { ROLE1 } from '../../helpers/Roles'; + +describe('initialized with zero address', () => { + it('should set caller as super admin', async () => { + const parcelNFT = await createParcelNFT(ZERO_ADDRESS); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.true; + }); + + it('should allow caller to change roles', async () => { + const parcelNFT = await createParcelNFT(ZERO_ADDRESS); + await parcelNFT.grantRole(ROLE1, USER1.address); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; + }); + + it('should not allow others to change roles', async () => { + const parcelNFT = await createParcelNFT(ZERO_ADDRESS); + await expect(parcelNFT.connect(USER1).grantRole(ROLE1, USER1.address)).to.be.rejectedWith('missing role'); + }); +}); + +describe('initialized with another address', () => { + it('should set caller as super admin', async () => { + const parcelNFT = await createParcelNFT(USER1.address); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + }); + + it('should allow caller to change roles', async () => { + const parcelNFT = await createParcelNFT(USER1.address); + await parcelNFT.connect(USER1).grantRole(ROLE1, USER2.address); + + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.true; + }); + + it('should not allow others to change roles', async () => { + const parcelNFT = await createParcelNFT(USER1.address); + await expect(parcelNFT.connect(USER2).grantRole(ROLE1, USER2.address)).to.be.rejectedWith('missing role'); + }); +}); diff --git a/test/helpers/ParcelNFTHelper.ts b/test/helpers/ParcelNFTHelper.ts new file mode 100644 index 0000000..b832e87 --- /dev/null +++ b/test/helpers/ParcelNFTHelper.ts @@ -0,0 +1,11 @@ +import { EthereumAddress, ZERO_ADDRESS } from '../../src/constants/accounts'; +import { ParcelNFT__factory } from '../../types/contracts'; +import { INITIALIZER } from './Accounts'; + +export const createParcelNFT = async (superAdmin: EthereumAddress = ZERO_ADDRESS) => { + const parcelNFT = await deployParcelNFT(); + await parcelNFT.initialize(superAdmin); + return parcelNFT; +}; + +export const deployParcelNFT = async () => new ParcelNFT__factory(INITIALIZER).deploy(); diff --git a/test/helpers/Roles.ts b/test/helpers/Roles.ts new file mode 100644 index 0000000..0da2c06 --- /dev/null +++ b/test/helpers/Roles.ts @@ -0,0 +1,5 @@ +import { toAccessRole } from '../../dist/contracts/access'; + +export const ROLE1 = toAccessRole(1); +export const ROLE2 = toAccessRole(2); +export const ROLE3 = toAccessRole(3); From f93c9017bc3b7afc4967d10b886311a9311f62f2 Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Thu, 21 Apr 2022 08:37:22 -0500 Subject: [PATCH 3/6] chore: [#1] added proxy helper and related tests --- .github/workflows/ci.yml | 2 +- contracts/ParcelNFT.sol | 7 +++- contracts/Roles.sol | 6 ++- hardhat.config.ts | 1 + package-lock.json | 11 +++++ package.json | 3 +- src/constants/accounts.ts | 4 +- src/constants/roles.ts | 4 +- src/contracts/parcelNFT.ts | 22 ++++++++++ src/contracts/upgradeableProxy.ts | 32 +++++++++++++++ test/contracts/access/access.spec.ts | 18 ++++++--- .../initialization/initialize.spec.ts | 5 +++ test/contracts/proxy/proxy.spec.ts | 40 +++++++++++++++++++ test/helpers/Roles.ts | 2 +- .../{ => contracts}/ParcelNFTHelper.ts | 6 +-- test/helpers/contracts/ProxyHelper.ts | 20 ++++++++++ test/helpers/storage.ts | 20 ++++++++++ 17 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 src/contracts/parcelNFT.ts create mode 100644 src/contracts/upgradeableProxy.ts create mode 100644 test/contracts/initialization/initialize.spec.ts create mode 100644 test/contracts/proxy/proxy.spec.ts rename test/helpers/{ => contracts}/ParcelNFTHelper.ts (60%) create mode 100644 test/helpers/contracts/ProxyHelper.ts create mode 100644 test/helpers/storage.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3413ad5..34fbfa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,4 @@ jobs: - name: Build run: npm run build - name: Test - run: npm run test -- --ci --maxWorkers=1 --reporters=default + run: npm test diff --git a/contracts/ParcelNFT.sol b/contracts/ParcelNFT.sol index 6419790..942b209 100644 --- a/contracts/ParcelNFT.sol +++ b/contracts/ParcelNFT.sol @@ -1,12 +1,13 @@ -// SPDX-License-Identifier: GPL-3.0-only +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.9; import '@gnus.ai/contracts-upgradeable-diamond/access/AccessControlUpgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/proxy/utils/UUPSUpgradeable.sol'; import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/ERC721Upgradeable.sol'; import './Roles.sol'; -contract ParcelNFT is AccessControlUpgradeable, ERC721Upgradeable { +contract ParcelNFT is UUPSUpgradeable, AccessControlUpgradeable, ERC721Upgradeable { function initialize(address superAdmin) public initializer { if (superAdmin == address(0)) { superAdmin = _msgSender(); @@ -23,4 +24,6 @@ contract ParcelNFT is AccessControlUpgradeable, ERC721Upgradeable { { return super.supportsInterface(interfaceId); } + + function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(Roles.UPGRADER_ROLE) {} } diff --git a/contracts/Roles.sol b/contracts/Roles.sol index f22d024..3c1d01f 100644 --- a/contracts/Roles.sol +++ b/contracts/Roles.sol @@ -1,8 +1,10 @@ -// SPDX-License-Identifier: GPL-3.0-only +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.9; library Roles { bytes32 public constant SUPER_ADMIN_ROLE = 0x00; - bytes32 public constant ADMIN_ROLE = keccak256('citydao.Admin'); + bytes32 public constant PARCEL_MANAGER_ROLE = keccak256('citydao.ParcelManager'); + bytes32 public constant PAUSER = keccak256('citydao.Pauser'); + bytes32 public constant UPGRADER_ROLE = keccak256('citydao.Upgrader'); } diff --git a/hardhat.config.ts b/hardhat.config.ts index 93bd8ed..b49bb79 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -15,6 +15,7 @@ const hardhatConfig: HardhatUserConfig = { }, typechain: { + externalArtifacts: ['node_modules/@openzeppelin/contracts/build/contracts/ERC1967Proxy.json'], outDir: 'types/contracts', target: 'ethers-v5', }, diff --git a/package-lock.json b/package-lock.json index 91336fb..19f097d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@gnus.ai/contracts-upgradeable-diamond": "4.5.0", + "@openzeppelin/contracts": "4.5.0", "ethers": "5.6.4" }, "devDependencies": { @@ -1630,6 +1631,11 @@ "hardhat": "^2.0.0" } }, + "node_modules/@openzeppelin/contracts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.5.0.tgz", + "integrity": "sha512-fdkzKPYMjrRiPK6K4y64e6GzULR7R7RwxSigHS8DDp7aWDeoReqsQI+cxHV1UuhAqX69L1lAaWDxenfP+xiqzA==" + }, "node_modules/@resolver-engine/core": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@resolver-engine/core/-/core-0.3.3.tgz", @@ -22518,6 +22524,11 @@ "@types/web3": "1.0.19" } }, + "@openzeppelin/contracts": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@openzeppelin/contracts/-/contracts-4.5.0.tgz", + "integrity": "sha512-fdkzKPYMjrRiPK6K4y64e6GzULR7R7RwxSigHS8DDp7aWDeoReqsQI+cxHV1UuhAqX69L1lAaWDxenfP+xiqzA==" + }, "@resolver-engine/core": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@resolver-engine/core/-/core-0.3.3.tgz", diff --git a/package.json b/package.json index e2c0d0b..9f1a9cd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ], "scripts": { "clean": "rm -rf build && rm -rf dist && rm -rf out && rm -rf types && rm -rf artifacts && rm -rf cache", - "compile:contracts": "hardhat compile && npm run pretty", + "compile:contracts": "hardhat compile", "build": "npm run compile:contracts && tsc && npm run copy-declarations", "build:watch": "tsc --watch", "copy-declarations": "cp -R types dist", @@ -43,6 +43,7 @@ }, "dependencies": { "@gnus.ai/contracts-upgradeable-diamond": "4.5.0", + "@openzeppelin/contracts": "4.5.0", "ethers": "5.6.4" }, "devDependencies": { diff --git a/src/constants/accounts.ts b/src/constants/accounts.ts index 21da09f..0e66c64 100644 --- a/src/constants/accounts.ts +++ b/src/constants/accounts.ts @@ -1,9 +1,11 @@ +import { BigNumber, ethers } from 'ethers'; import { toFixedByteString } from '../utils/fixedBytes'; export type AccountAddress = string; export type ContractAddress = string; export type EthereumAddress = AccountAddress | ContractAddress; -export const toEthereumAddress = (value: number): EthereumAddress => toFixedByteString(value, 20); +export const toEthereumAddress = (value: number | BigNumber): EthereumAddress => + ethers.utils.getAddress(toFixedByteString(value, 20)); export const ZERO_ADDRESS = toEthereumAddress(0); diff --git a/src/constants/roles.ts b/src/constants/roles.ts index bc01139..2a82bd8 100644 --- a/src/constants/roles.ts +++ b/src/constants/roles.ts @@ -7,4 +7,6 @@ export type AccessRole = string; export const toAccessRole = (value: BytesLike | Hexable | number): AccessRole => toByte32String(value); export const SUPER_ADMIN_ROLE = toAccessRole(0); -export const ADMIN_ROLE = keccak256(toUtf8Bytes('citydao.Admin')); +export const PARCEL_MANAGER = keccak256(toUtf8Bytes('citydao.ParcelManager')); +export const PAUSER_ROLE = keccak256(toUtf8Bytes('citydao.Pauser')); +export const UPGRADER_ROLE = keccak256(toUtf8Bytes('citydao.Upgrader')); diff --git a/src/contracts/parcelNFT.ts b/src/contracts/parcelNFT.ts new file mode 100644 index 0000000..73aa73d --- /dev/null +++ b/src/contracts/parcelNFT.ts @@ -0,0 +1,22 @@ +import { Signer } from 'ethers'; +import { ParcelNFT, ParcelNFT__factory } from '../../types/contracts'; +import { ContractAddress, EthereumAddress, ZERO_ADDRESS } from '../constants/accounts'; +import { deployUpgradeableProxy } from './upgradeableProxy'; + +/** + * Creates and initializes an upgradeable Parcel NFT contract using the already-deployed + * ParcelNFT contract for implementation + * + * @param signer the signer to use to deploy the proxy + * @param parcelNFTLogicAddress the deployed parcel contract that will be used for logic + * @param superAdmin (optional) the super-admin address. Default is the caller. + */ +export const createParcelNFT = async ( + signer: Signer, + parcelNFTLogicAddress: ContractAddress, + superAdmin: EthereumAddress = ZERO_ADDRESS, +): Promise => { + const initFunction = ParcelNFT__factory.createInterface().encodeFunctionData('initialize', [superAdmin]); + const proxy = await deployUpgradeableProxy(signer, parcelNFTLogicAddress, initFunction); + return ParcelNFT__factory.connect(proxy.address, signer); +}; diff --git a/src/contracts/upgradeableProxy.ts b/src/contracts/upgradeableProxy.ts new file mode 100644 index 0000000..ed137f3 --- /dev/null +++ b/src/contracts/upgradeableProxy.ts @@ -0,0 +1,32 @@ +import { Signer } from 'ethers'; +import { BytesLike } from 'ethers/lib/utils'; +import { ERC1967Proxy__factory } from '../../types/contracts'; +import { ContractAddress } from '../constants/accounts'; + +/** + * Deploy an upgradeable proxy contract with the given contract address to be used + * for the logic and initialization function + * + * @param signer The signer to use for deployment + * @param logicAddress the address of the contract to use for the implementation logic + * @param initFunction (optional) the initialization function, abi-encoded. Defaults to empty + */ +export const deployUpgradeableProxy = async ( + signer: Signer, + logicAddress: ContractAddress, + initFunction: BytesLike = '0x', +) => new ERC1967Proxy__factory(signer).deploy(logicAddress, initFunction); + +/** + * Build a transaction to deploy an upgradeable proxy contract with the given contract address + * to be used for the logic and initialization function + * + * @param signer The signer to use for deployment + * @param logicAddress the address of the contract to use for the implementation logic + * @param initFunction (optional) the initialization function, abi-encoded. Defaults to empty + */ +export const buildUpgradeableProxyDeployment = async ( + signer: Signer, + logicAddress: ContractAddress, + initFunction: BytesLike = '0x', +) => new ERC1967Proxy__factory().getDeployTransaction(logicAddress, initFunction); diff --git a/test/contracts/access/access.spec.ts b/test/contracts/access/access.spec.ts index 6b815ee..3f145fe 100644 --- a/test/contracts/access/access.spec.ts +++ b/test/contracts/access/access.spec.ts @@ -1,44 +1,50 @@ import { expect } from 'chai'; -import { SUPER_ADMIN_ROLE } from '../../../dist/constants/roles'; import { ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { SUPER_ADMIN_ROLE } from '../../../src/constants/roles'; import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; -import { createParcelNFT } from '../../helpers/ParcelNFTHelper'; +import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; import { ROLE1 } from '../../helpers/Roles'; describe('initialized with zero address', () => { it('should set caller as super admin', async () => { const parcelNFT = await createParcelNFT(ZERO_ADDRESS); - expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.true; + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.true; + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.false; }); it('should allow caller to change roles', async () => { const parcelNFT = await createParcelNFT(ZERO_ADDRESS); await parcelNFT.grantRole(ROLE1, USER1.address); - expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; + expect(parcelNFT.hasRole(ROLE1, USER1.address)).to.eventually.be.true; }); it('should not allow others to change roles', async () => { const parcelNFT = await createParcelNFT(ZERO_ADDRESS); await expect(parcelNFT.connect(USER1).grantRole(ROLE1, USER1.address)).to.be.rejectedWith('missing role'); + + expect(parcelNFT.hasRole(ROLE1, USER1.address)).to.eventually.be.false; }); }); describe('initialized with another address', () => { it('should set caller as super admin', async () => { const parcelNFT = await createParcelNFT(USER1.address); - expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.true; + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.false; }); it('should allow caller to change roles', async () => { const parcelNFT = await createParcelNFT(USER1.address); await parcelNFT.connect(USER1).grantRole(ROLE1, USER2.address); - expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.true; + expect(parcelNFT.hasRole(ROLE1, USER2.address)).to.eventually.be.true; }); it('should not allow others to change roles', async () => { const parcelNFT = await createParcelNFT(USER1.address); await expect(parcelNFT.connect(USER2).grantRole(ROLE1, USER2.address)).to.be.rejectedWith('missing role'); + + expect(parcelNFT.hasRole(ROLE1, USER2.address)).to.eventually.be.false; }); }); diff --git a/test/contracts/initialization/initialize.spec.ts b/test/contracts/initialization/initialize.spec.ts new file mode 100644 index 0000000..245615c --- /dev/null +++ b/test/contracts/initialization/initialize.spec.ts @@ -0,0 +1,5 @@ +describe('initialize', () => { + it('should initialize the contract', async () => {}); + + it('should not allow initialization twice', async () => {}); +}); diff --git a/test/contracts/proxy/proxy.spec.ts b/test/contracts/proxy/proxy.spec.ts new file mode 100644 index 0000000..6f5d163 --- /dev/null +++ b/test/contracts/proxy/proxy.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { SUPER_ADMIN_ROLE, UPGRADER_ROLE } from '../../../src/constants/roles'; +import { createParcelNFT } from '../../../src/contracts/parcelNFT'; +import { INITIALIZER, USER1 } from '../../helpers/Accounts'; +import { deployParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; +import { getProxyImplementationAddress } from '../../helpers/contracts/ProxyHelper'; + +describe('createParcelNFT', () => { + it('should create and initialize the contract', async () => { + const parcelNFTLogic = await deployParcelNFT(); + const parcelNFT = await createParcelNFT(INITIALIZER, parcelNFTLogic.address, USER1.address); + + expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(parcelNFTLogic.address); + + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.true; + expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.false; + }); + + it('should create upgradeable contract', async () => { + const parcelNFTLogic = await deployParcelNFT(); + const parcelNFT = await createParcelNFT(INITIALIZER, parcelNFTLogic.address); + await parcelNFT.grantRole(UPGRADER_ROLE, INITIALIZER.address); + + const newParcelNFTLogic = await deployParcelNFT(); + await parcelNFT.upgradeTo(newParcelNFTLogic.address); + + expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(newParcelNFTLogic.address); + }); + + it('should fail to upgrade when not an upgrader', async () => { + const parcelNFTLogic = await deployParcelNFT(); + const parcelNFT = await createParcelNFT(INITIALIZER, parcelNFTLogic.address); + await parcelNFT.grantRole(UPGRADER_ROLE, INITIALIZER.address); + + const newParcelNFTLogic = await deployParcelNFT(); + await expect(parcelNFT.connect(USER1).upgradeTo(newParcelNFTLogic.address)).to.be.rejectedWith('missing role'); + + expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(parcelNFTLogic.address); + }); +}); diff --git a/test/helpers/Roles.ts b/test/helpers/Roles.ts index 0da2c06..016459c 100644 --- a/test/helpers/Roles.ts +++ b/test/helpers/Roles.ts @@ -1,4 +1,4 @@ -import { toAccessRole } from '../../dist/contracts/access'; +import { toAccessRole } from '../../src/constants/roles'; export const ROLE1 = toAccessRole(1); export const ROLE2 = toAccessRole(2); diff --git a/test/helpers/ParcelNFTHelper.ts b/test/helpers/contracts/ParcelNFTHelper.ts similarity index 60% rename from test/helpers/ParcelNFTHelper.ts rename to test/helpers/contracts/ParcelNFTHelper.ts index b832e87..70c2f12 100644 --- a/test/helpers/ParcelNFTHelper.ts +++ b/test/helpers/contracts/ParcelNFTHelper.ts @@ -1,6 +1,6 @@ -import { EthereumAddress, ZERO_ADDRESS } from '../../src/constants/accounts'; -import { ParcelNFT__factory } from '../../types/contracts'; -import { INITIALIZER } from './Accounts'; +import { EthereumAddress, ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { ParcelNFT__factory } from '../../../types/contracts'; +import { INITIALIZER } from '../Accounts'; export const createParcelNFT = async (superAdmin: EthereumAddress = ZERO_ADDRESS) => { const parcelNFT = await deployParcelNFT(); diff --git a/test/helpers/contracts/ProxyHelper.ts b/test/helpers/contracts/ProxyHelper.ts new file mode 100644 index 0000000..3ecfdf1 --- /dev/null +++ b/test/helpers/contracts/ProxyHelper.ts @@ -0,0 +1,20 @@ +import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/signers'; +import { BigNumber, Contract } from 'ethers'; +import { toEthereumAddress } from '../../../src/constants/accounts'; +import { ERC1967Proxy__factory } from '../../../types/contracts'; +import { INITIALIZER } from '../Accounts'; +import { getStorageLocationFromText, readStorageValue } from '../storage'; + +export const asProxy = (contract: Contract, signer: SignerWithAddress = INITIALIZER) => + ERC1967Proxy__factory.connect(contract.address, signer); + +export const getProxyImplementationAddress = async (contract: Contract) => + toEthereumAddress( + BigNumber.from( + await readStorageValue( + INITIALIZER.provider!!, + contract.address, + getStorageLocationFromText('eip1967.proxy.implementation').sub(1), + ), + ), + ); diff --git a/test/helpers/storage.ts b/test/helpers/storage.ts new file mode 100644 index 0000000..9d00647 --- /dev/null +++ b/test/helpers/storage.ts @@ -0,0 +1,20 @@ +import { BigNumber, providers } from 'ethers'; +import { keccak256, toUtf8Bytes } from 'ethers/lib/utils'; +import { ContractAddress } from '../../src/constants/accounts'; + +export const readStorageValue = async (provider: providers.Provider, address: ContractAddress, location: BigNumber) => + (await readStorageValues(provider, address, location, 1))[0]; + +export const readStorageValues = async ( + provider: providers.Provider, + address: ContractAddress, + location: BigNumber, + numValues: number, +) => + Promise.all( + Array.from(Array(numValues)).map((_, index) => provider.getStorageAt(address, location.add(index))), + ); + +export const getStorageLocationFromText = (location: string) => BigNumber.from(keccak256(toUtf8Bytes(location))); + +export const getStorageLocationFromBigNumber = (index: BigNumber) => BigNumber.from(keccak256(index.toHexString())); From 75006278d0395da430314c1ff59a4cea48979345 Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Thu, 21 Apr 2022 22:53:34 -0500 Subject: [PATCH 4/6] chore: [#1] added pausable and tokenURI support --- contracts/ParcelNFT.sol | 77 ++++++++++++++-- contracts/ParcelNFTStorage.sol | 29 +++++++ contracts/Roles.sol | 6 +- package-lock.json | 22 ----- package.json | 1 - src/constants/roles.ts | 2 +- src/contracts/parcelNFT.ts | 25 +++++- test/contracts/access/access.spec.ts | 32 +++---- .../initialization/initialize.spec.ts | 36 +++++++- test/contracts/pausable/pausable.spec.ts | 87 +++++++++++++++++++ test/contracts/proxy/proxy.spec.ts | 14 +-- test/contracts/tokenURI/tokenURI.spec.ts | 81 +++++++++++++++++ test/helpers/contracts/ParcelNFTHelper.ts | 6 +- test/setup/ChaiSetup.ts | 2 - 14 files changed, 353 insertions(+), 67 deletions(-) create mode 100644 contracts/ParcelNFTStorage.sol create mode 100644 test/contracts/pausable/pausable.spec.ts create mode 100644 test/contracts/tokenURI/tokenURI.spec.ts diff --git a/contracts/ParcelNFT.sol b/contracts/ParcelNFT.sol index 942b209..9bd455d 100644 --- a/contracts/ParcelNFT.sol +++ b/contracts/ParcelNFT.sol @@ -4,15 +4,33 @@ pragma solidity ^0.8.9; import '@gnus.ai/contracts-upgradeable-diamond/access/AccessControlUpgradeable.sol'; import '@gnus.ai/contracts-upgradeable-diamond/proxy/utils/UUPSUpgradeable.sol'; -import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/ERC721Upgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/security/PausableUpgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol'; +import './ParcelNFTStorage.sol'; import './Roles.sol'; -contract ParcelNFT is UUPSUpgradeable, AccessControlUpgradeable, ERC721Upgradeable { - function initialize(address superAdmin) public initializer { - if (superAdmin == address(0)) { - superAdmin = _msgSender(); +contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessControlUpgradeable, PausableUpgradeable { + struct InitParams { + string name; + string symbol; + address superAdmin; + } + + function initialize(InitParams memory initParams) public initializer { + __ERC721_init(initParams.name, initParams.symbol); + __ERC721URIStorage_init_unchained(); + __AccessControl_init_unchained(); + __Pausable_init_unchained(); + + if (initParams.superAdmin == address(0)) { + initParams.superAdmin = _msgSender(); } - _setupRole(Roles.SUPER_ADMIN_ROLE, superAdmin); + _setupRole(Roles.SUPER_ADMIN, initParams.superAdmin); + } + + // todo: temporary until minting is supported + function mint(uint256 tokenId) external onlyRole(Roles.PARCEL_MANAGER) { + _safeMint(_msgSender(), tokenId); } function supportsInterface(bytes4 interfaceId) @@ -25,5 +43,50 @@ contract ParcelNFT is UUPSUpgradeable, AccessControlUpgradeable, ERC721Upgradeab return super.supportsInterface(interfaceId); } - function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(Roles.UPGRADER_ROLE) {} + /** + * @notice Sets `baseURI` as the base URI for all tokens. Used when explicit tokenURI not set. + */ + function setBaseURI(string calldata baseURI) external onlyRole(Roles.PARCEL_MANAGER) { + ParcelNFTStorage.setBaseURI(baseURI); + } + + function _baseURI() internal view virtual override returns (string memory) { + return ParcelNFTStorage.baseURI(); + } + + /** + * @notice Sets `_tokenURI` as the tokenURI of `tokenId`. + * + * Requirements: + * + * - `tokenId` must exist. + */ + function setTokenURI(uint256 tokenId, string memory _tokenURI) external onlyRole(Roles.PARCEL_MANAGER) { + _setTokenURI(tokenId, _tokenURI); + } + + /** + * @notice Triggers stopped state. + * + * Requirements: + * + * - The contract must not be paused. + */ + function pause() external onlyRole(Roles.PAUSER) { + _pause(); + } + + /** + * @notice Returns to normal state. + * + * Requirements: + * + * - The contract must be paused. + */ + function unpause() external onlyRole(Roles.PAUSER) { + _unpause(); + } + + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(Roles.UPGRADER) {} } diff --git a/contracts/ParcelNFTStorage.sol b/contracts/ParcelNFTStorage.sol new file mode 100644 index 0000000..b445488 --- /dev/null +++ b/contracts/ParcelNFTStorage.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity ^0.8.9; + +library ParcelNFTStorage { + struct Layout { + // storage for base URI + string baseURI; + } + + bytes32 internal constant STORAGE_SLOT = keccak256('citydao.contracts.storage.ParcelNFT'); + + //noinspection NoReturn + function layout() internal pure returns (Layout storage _layout) { + bytes32 slot = STORAGE_SLOT; + // solhint-disable-next-line no-inline-assembly + assembly { + _layout.slot := slot + } + } + + function baseURI() internal view returns (string memory) { + return layout().baseURI; + } + + function setBaseURI(string memory _baseURI) internal { + layout().baseURI = _baseURI; + } +} diff --git a/contracts/Roles.sol b/contracts/Roles.sol index 3c1d01f..d7dcbea 100644 --- a/contracts/Roles.sol +++ b/contracts/Roles.sol @@ -3,8 +3,8 @@ pragma solidity ^0.8.9; library Roles { - bytes32 public constant SUPER_ADMIN_ROLE = 0x00; - bytes32 public constant PARCEL_MANAGER_ROLE = keccak256('citydao.ParcelManager'); + bytes32 public constant SUPER_ADMIN = 0x00; + bytes32 public constant PARCEL_MANAGER = keccak256('citydao.ParcelManager'); bytes32 public constant PAUSER = keccak256('citydao.Pauser'); - bytes32 public constant UPGRADER_ROLE = keccak256('citydao.Upgrader'); + bytes32 public constant UPGRADER = keccak256('citydao.Upgrader'); } diff --git a/package-lock.json b/package-lock.json index 19f097d..46417a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,6 @@ "@typescript-eslint/eslint-plugin": "5.19.0", "@typescript-eslint/parser": "5.19.0", "chai": "4.3.6", - "chai-as-promised": "7.1.1", "cz-conventional-changelog": "3.3.0", "eslint": "8.13.0", "eslint-plugin-import": "2.26.0", @@ -2984,18 +2983,6 @@ "node": ">=4" } }, - "node_modules/chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", - "dev": true, - "dependencies": { - "check-error": "^1.0.2" - }, - "peerDependencies": { - "chai": ">= 2.1.2 < 5" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -23610,15 +23597,6 @@ "type-detect": "^4.0.5" } }, - "chai-as-promised": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz", - "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==", - "dev": true, - "requires": { - "check-error": "^1.0.2" - } - }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 9f1a9cd..7711d92 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "@typescript-eslint/eslint-plugin": "5.19.0", "@typescript-eslint/parser": "5.19.0", "chai": "4.3.6", - "chai-as-promised": "7.1.1", "cz-conventional-changelog": "3.3.0", "eslint": "8.13.0", "eslint-plugin-import": "2.26.0", diff --git a/src/constants/roles.ts b/src/constants/roles.ts index 2a82bd8..9c3d19e 100644 --- a/src/constants/roles.ts +++ b/src/constants/roles.ts @@ -7,6 +7,6 @@ export type AccessRole = string; export const toAccessRole = (value: BytesLike | Hexable | number): AccessRole => toByte32String(value); export const SUPER_ADMIN_ROLE = toAccessRole(0); -export const PARCEL_MANAGER = keccak256(toUtf8Bytes('citydao.ParcelManager')); +export const PARCEL_MANAGER_ROLE = keccak256(toUtf8Bytes('citydao.ParcelManager')); export const PAUSER_ROLE = keccak256(toUtf8Bytes('citydao.Pauser')); export const UPGRADER_ROLE = keccak256(toUtf8Bytes('citydao.Upgrader')); diff --git a/src/contracts/parcelNFT.ts b/src/contracts/parcelNFT.ts index 73aa73d..29fecc2 100644 --- a/src/contracts/parcelNFT.ts +++ b/src/contracts/parcelNFT.ts @@ -3,20 +3,39 @@ import { ParcelNFT, ParcelNFT__factory } from '../../types/contracts'; import { ContractAddress, EthereumAddress, ZERO_ADDRESS } from '../constants/accounts'; import { deployUpgradeableProxy } from './upgradeableProxy'; +export interface ParcelNFTInitParams { + // the name of the token + name: string; + + // the symbol for the token + symbol: string; + + // the super-admin address. Default is the caller. + superAdmin: EthereumAddress; +} + +export const defaultParcelNFTInitParams: ParcelNFTInitParams = { + name: '', + symbol: '', + superAdmin: ZERO_ADDRESS, +}; + /** * Creates and initializes an upgradeable Parcel NFT contract using the already-deployed * ParcelNFT contract for implementation * * @param signer the signer to use to deploy the proxy * @param parcelNFTLogicAddress the deployed parcel contract that will be used for logic - * @param superAdmin (optional) the super-admin address. Default is the caller. + * @param initParams (optional) the initialization parameters */ export const createParcelNFT = async ( signer: Signer, parcelNFTLogicAddress: ContractAddress, - superAdmin: EthereumAddress = ZERO_ADDRESS, + initParams: Partial = {}, ): Promise => { - const initFunction = ParcelNFT__factory.createInterface().encodeFunctionData('initialize', [superAdmin]); + const initFunction = ParcelNFT__factory.createInterface().encodeFunctionData('initialize', [ + { ...defaultParcelNFTInitParams, ...initParams }, + ]); const proxy = await deployUpgradeableProxy(signer, parcelNFTLogicAddress, initFunction); return ParcelNFT__factory.connect(proxy.address, signer); }; diff --git a/test/contracts/access/access.spec.ts b/test/contracts/access/access.spec.ts index 3f145fe..dfba9b9 100644 --- a/test/contracts/access/access.spec.ts +++ b/test/contracts/access/access.spec.ts @@ -7,44 +7,44 @@ import { ROLE1 } from '../../helpers/Roles'; describe('initialized with zero address', () => { it('should set caller as super admin', async () => { - const parcelNFT = await createParcelNFT(ZERO_ADDRESS); - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.true; - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.false; + const parcelNFT = await createParcelNFT({ superAdmin: ZERO_ADDRESS }); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.true; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.false; }); it('should allow caller to change roles', async () => { - const parcelNFT = await createParcelNFT(ZERO_ADDRESS); + const parcelNFT = await createParcelNFT({ superAdmin: ZERO_ADDRESS }); await parcelNFT.grantRole(ROLE1, USER1.address); - expect(parcelNFT.hasRole(ROLE1, USER1.address)).to.eventually.be.true; + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; }); it('should not allow others to change roles', async () => { - const parcelNFT = await createParcelNFT(ZERO_ADDRESS); - await expect(parcelNFT.connect(USER1).grantRole(ROLE1, USER1.address)).to.be.rejectedWith('missing role'); + const parcelNFT = await createParcelNFT({ superAdmin: ZERO_ADDRESS }); + await expect(parcelNFT.connect(USER1).grantRole(ROLE1, USER1.address)).to.be.revertedWith('missing role'); - expect(parcelNFT.hasRole(ROLE1, USER1.address)).to.eventually.be.false; + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.false; }); }); describe('initialized with another address', () => { it('should set caller as super admin', async () => { - const parcelNFT = await createParcelNFT(USER1.address); - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.true; - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.false; + const parcelNFT = await createParcelNFT({ superAdmin: USER1.address }); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.false; }); it('should allow caller to change roles', async () => { - const parcelNFT = await createParcelNFT(USER1.address); + const parcelNFT = await createParcelNFT({ superAdmin: USER1.address }); await parcelNFT.connect(USER1).grantRole(ROLE1, USER2.address); - expect(parcelNFT.hasRole(ROLE1, USER2.address)).to.eventually.be.true; + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.true; }); it('should not allow others to change roles', async () => { - const parcelNFT = await createParcelNFT(USER1.address); - await expect(parcelNFT.connect(USER2).grantRole(ROLE1, USER2.address)).to.be.rejectedWith('missing role'); + const parcelNFT = await createParcelNFT({ superAdmin: USER1.address }); + await expect(parcelNFT.connect(USER2).grantRole(ROLE1, USER2.address)).to.be.revertedWith('missing role'); - expect(parcelNFT.hasRole(ROLE1, USER2.address)).to.eventually.be.false; + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.false; }); }); diff --git a/test/contracts/initialization/initialize.spec.ts b/test/contracts/initialization/initialize.spec.ts index 245615c..8d2ec43 100644 --- a/test/contracts/initialization/initialize.spec.ts +++ b/test/contracts/initialization/initialize.spec.ts @@ -1,5 +1,37 @@ +import { expect } from 'chai'; +import { SUPER_ADMIN_ROLE } from '../../../src/constants/roles'; +import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; +import { deployParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; + describe('initialize', () => { - it('should initialize the contract', async () => {}); + it('should initialize the contract', async () => { + const parcelNFT = await deployParcelNFT(); + + expect(await parcelNFT.name()).to.eq(''); + expect(await parcelNFT.symbol()).to.eq(''); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.false; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.false; + + await parcelNFT.initialize({ name: 'the name', symbol: 'the symbol', superAdmin: USER1.address }); + + expect(await parcelNFT.name()).to.eq('the name'); + expect(await parcelNFT.symbol()).to.eq('the symbol'); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.false; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + }); + + it('should not allow initialization twice', async () => { + const parcelNFT = await deployParcelNFT(); + await parcelNFT.initialize({ name: 'the name', symbol: 'the symbol', superAdmin: USER1.address }); + + expect( + parcelNFT.initialize({ name: 'the new name', symbol: 'the new symbol', superAdmin: USER2.address }), + ).to.be.revertedWith('contract is already initialized'); - it('should not allow initialization twice', async () => {}); + expect(await parcelNFT.name()).to.eq('the name'); + expect(await parcelNFT.symbol()).to.eq('the symbol'); + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.false; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER2.address)).to.be.false; + }); }); diff --git a/test/contracts/pausable/pausable.spec.ts b/test/contracts/pausable/pausable.spec.ts new file mode 100644 index 0000000..d2d95ec --- /dev/null +++ b/test/contracts/pausable/pausable.spec.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { PAUSER_ROLE } from '../../../src/constants/roles'; +import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; +import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; + +describe('pause', () => { + it('should pause the contract', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + expect(await parcelNFT.paused()).to.be.false; + + await parcelNFT.grantRole(PAUSER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).pause(); + + expect(await parcelNFT.paused()).to.be.true; + }); + + it('should fail if called by non-pauser', async () => { + const parcelNFT = await createParcelNFT(); + + expect(parcelNFT.connect(USER1).pause()).to.be.revertedWith('missing role'); + + expect(await parcelNFT.paused()).to.be.false; + + await parcelNFT.grantRole(PAUSER_ROLE, USER1.address); + + expect(parcelNFT.connect(USER2).pause()).to.be.revertedWith('missing role'); + + expect(await parcelNFT.paused()).to.be.false; + }); + + it('should fail if called when paused', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + await parcelNFT.pause(); + + expect(parcelNFT.pause()).to.be.revertedWith('paused'); + + expect(await parcelNFT.paused()).to.be.true; + }); +}); + +describe('unpause', () => { + it('should unpause the contract', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + await parcelNFT.pause(); + + expect(await parcelNFT.paused()).to.be.true; + + await parcelNFT.grantRole(PAUSER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).unpause(); + + expect(await parcelNFT.paused()).to.be.false; + }); + + it('should fail if called by non-pauser', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + await parcelNFT.pause(); + + expect(parcelNFT.connect(USER1).unpause()).to.be.revertedWith('missing role'); + + expect(await parcelNFT.paused()).to.be.true; + + await parcelNFT.grantRole(PAUSER_ROLE, USER1.address); + + expect(parcelNFT.connect(USER2).unpause()).to.be.revertedWith('missing role'); + + expect(await parcelNFT.paused()).to.be.true; + }); + + it('should fail if called when unpaused', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + expect(parcelNFT.unpause()).to.be.revertedWith('not paused'); + + expect(await parcelNFT.paused()).to.be.false; + }); +}); diff --git a/test/contracts/proxy/proxy.spec.ts b/test/contracts/proxy/proxy.spec.ts index 6f5d163..3101339 100644 --- a/test/contracts/proxy/proxy.spec.ts +++ b/test/contracts/proxy/proxy.spec.ts @@ -8,12 +8,12 @@ import { getProxyImplementationAddress } from '../../helpers/contracts/ProxyHelp describe('createParcelNFT', () => { it('should create and initialize the contract', async () => { const parcelNFTLogic = await deployParcelNFT(); - const parcelNFT = await createParcelNFT(INITIALIZER, parcelNFTLogic.address, USER1.address); + const parcelNFT = await createParcelNFT(INITIALIZER, parcelNFTLogic.address, { superAdmin: USER1.address }); - expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(parcelNFTLogic.address); + expect(await getProxyImplementationAddress(parcelNFT)).to.eq(parcelNFTLogic.address); - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.eventually.be.true; - expect(parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.eventually.be.false; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(SUPER_ADMIN_ROLE, INITIALIZER.address)).to.be.false; }); it('should create upgradeable contract', async () => { @@ -24,7 +24,7 @@ describe('createParcelNFT', () => { const newParcelNFTLogic = await deployParcelNFT(); await parcelNFT.upgradeTo(newParcelNFTLogic.address); - expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(newParcelNFTLogic.address); + expect(await getProxyImplementationAddress(parcelNFT)).to.eq(newParcelNFTLogic.address); }); it('should fail to upgrade when not an upgrader', async () => { @@ -33,8 +33,8 @@ describe('createParcelNFT', () => { await parcelNFT.grantRole(UPGRADER_ROLE, INITIALIZER.address); const newParcelNFTLogic = await deployParcelNFT(); - await expect(parcelNFT.connect(USER1).upgradeTo(newParcelNFTLogic.address)).to.be.rejectedWith('missing role'); + await expect(parcelNFT.connect(USER1).upgradeTo(newParcelNFTLogic.address)).to.be.revertedWith('missing role'); - expect(getProxyImplementationAddress(parcelNFT)).to.eventually.eq(parcelNFTLogic.address); + expect(await getProxyImplementationAddress(parcelNFT)).to.eq(parcelNFTLogic.address); }); }); diff --git a/test/contracts/tokenURI/tokenURI.spec.ts b/test/contracts/tokenURI/tokenURI.spec.ts new file mode 100644 index 0000000..268f9e2 --- /dev/null +++ b/test/contracts/tokenURI/tokenURI.spec.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai'; +import { PARCEL_MANAGER_ROLE } from '../../../src/constants/roles'; +import { INITIALIZER, USER1 } from '../../helpers/Accounts'; +import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; + +describe('setBaseURI', () => { + it('should set baseURI', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).mint(100); + + expect(await parcelNFT.tokenURI(100)).to.eq(''); + + await parcelNFT.connect(USER1).setBaseURI('the-base/'); + + expect(await parcelNFT.tokenURI(100)).to.eq('the-base/100'); + }); + + it('should fail if not called with parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, INITIALIZER.address); + + await parcelNFT.mint(100); + + expect(parcelNFT.connect(USER1).setBaseURI('the-base/')).to.be.revertedWith('missing role'); + + expect(await parcelNFT.tokenURI(100)).to.eq(''); + }); +}); + +describe('setTokenURI', () => { + it('should set the individual token uri', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).mint(100); + + expect(await parcelNFT.tokenURI(100)).to.eq(''); + + await parcelNFT.connect(USER1).setTokenURI(100, 'the-best-token'); + + expect(await parcelNFT.tokenURI(100)).to.eq('the-best-token'); + }); + + it('should fail if not called with parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, INITIALIZER.address); + + await parcelNFT.mint(100); + + expect(parcelNFT.connect(USER1).setTokenURI(100, 'the-best-token')).to.be.revertedWith('missing role'); + + expect(await parcelNFT.tokenURI(100)).to.eq(''); + }); + + it('should fail if called with an invalid tokenId', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, INITIALIZER.address); + + expect(parcelNFT.connect(USER1).setTokenURI(100, 'the-best-token')).to.be.revertedWith('missing role'); + }); +}); + +describe('tokenURI', () => { + it('should return the concatenated uri when both are set', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).mint(100); + await parcelNFT.connect(USER1).mint(101); + + expect(await parcelNFT.tokenURI(100)).to.eq(''); + + await parcelNFT.connect(USER1).setBaseURI('the-base/'); + await parcelNFT.connect(USER1).setTokenURI(100, 'the-best-token'); + + expect(await parcelNFT.tokenURI(100)).to.eq('the-base/the-best-token'); + expect(await parcelNFT.tokenURI(101)).to.eq('the-base/101'); + }); +}); diff --git a/test/helpers/contracts/ParcelNFTHelper.ts b/test/helpers/contracts/ParcelNFTHelper.ts index 70c2f12..c76231f 100644 --- a/test/helpers/contracts/ParcelNFTHelper.ts +++ b/test/helpers/contracts/ParcelNFTHelper.ts @@ -1,10 +1,10 @@ -import { EthereumAddress, ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { defaultParcelNFTInitParams, ParcelNFTInitParams } from '../../../src/contracts/parcelNFT'; import { ParcelNFT__factory } from '../../../types/contracts'; import { INITIALIZER } from '../Accounts'; -export const createParcelNFT = async (superAdmin: EthereumAddress = ZERO_ADDRESS) => { +export const createParcelNFT = async (initParams: Partial = {}) => { const parcelNFT = await deployParcelNFT(); - await parcelNFT.initialize(superAdmin); + await parcelNFT.initialize({ ...defaultParcelNFTInitParams, ...initParams }); return parcelNFT; }; diff --git a/test/setup/ChaiSetup.ts b/test/setup/ChaiSetup.ts index 51a573b..187f753 100644 --- a/test/setup/ChaiSetup.ts +++ b/test/setup/ChaiSetup.ts @@ -1,6 +1,4 @@ import chai from 'chai'; import { solidity } from 'ethereum-waffle'; -import chaiAsPromised from 'chai-as-promised'; chai.use(solidity); -chai.use(chaiAsPromised); From e286b8040f7933ee6cb2b2474921c6fbed13b4c9 Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Sat, 23 Apr 2022 09:49:40 -0500 Subject: [PATCH 5/6] chore: [#1] added pausable and access event tests --- test/contracts/access/access.spec.ts | 72 +++++++++++++++++++++++- test/contracts/pausable/pausable.spec.ts | 20 +++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/test/contracts/access/access.spec.ts b/test/contracts/access/access.spec.ts index dfba9b9..7d854e4 100644 --- a/test/contracts/access/access.spec.ts +++ b/test/contracts/access/access.spec.ts @@ -3,7 +3,7 @@ import { ZERO_ADDRESS } from '../../../src/constants/accounts'; import { SUPER_ADMIN_ROLE } from '../../../src/constants/roles'; import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; -import { ROLE1 } from '../../helpers/Roles'; +import { ROLE1, ROLE2 } from '../../helpers/Roles'; describe('initialized with zero address', () => { it('should set caller as super admin', async () => { @@ -48,3 +48,73 @@ describe('initialized with another address', () => { expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.false; }); }); + +describe('grantRole', async () => { + it('should grant the role to the given user', async () => { + const parcelNFT = await createParcelNFT(); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.false; + + await parcelNFT.grantRole(ROLE1, USER1.address); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(ROLE2, USER1.address)).to.be.false; + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.false; + }); + + it('should not fail if called by other than role admin', async () => { + const parcelNFT = await createParcelNFT(); + + await expect(parcelNFT.connect(USER1).grantRole(ROLE1, USER2.address)).to.be.revertedWith('missing role'); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.false; + }); + + it('should emit RoleGranted', async () => { + const parcelNFT = await createParcelNFT(); + + expect(await parcelNFT.grantRole(ROLE1, USER1.address)) + .to.emit(parcelNFT, 'RoleGranted') + .withArgs(ROLE1, USER1.address, INITIALIZER.address); + }); +}); + +describe('revokeRole', async () => { + it('should remove the role from the given user', async () => { + const parcelNFT = await createParcelNFT(); + + await parcelNFT.grantRole(ROLE1, USER1.address); + await parcelNFT.grantRole(ROLE2, USER1.address); + await parcelNFT.grantRole(ROLE1, USER2.address); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(ROLE2, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.true; + + await parcelNFT.revokeRole(ROLE1, USER1.address); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.false; + expect(await parcelNFT.hasRole(ROLE2, USER1.address)).to.be.true; + expect(await parcelNFT.hasRole(ROLE1, USER2.address)).to.be.true; + }); + + it('should not fail if called by other than role admin', async () => { + const parcelNFT = await createParcelNFT(); + + await parcelNFT.grantRole(ROLE1, USER1.address); + + await expect(parcelNFT.connect(USER1).revokeRole(ROLE1, USER2.address)).to.be.revertedWith('missing role'); + + expect(await parcelNFT.hasRole(ROLE1, USER1.address)).to.be.true; + }); + + it('should emit RoleRevoked', async () => { + const parcelNFT = await createParcelNFT(); + + await parcelNFT.grantRole(ROLE1, USER1.address); + + expect(await parcelNFT.revokeRole(ROLE1, USER1.address)) + .to.emit(parcelNFT, 'RoleRevoked') + .withArgs(ROLE1, USER1.address, INITIALIZER.address); + }); +}); diff --git a/test/contracts/pausable/pausable.spec.ts b/test/contracts/pausable/pausable.spec.ts index d2d95ec..fdc5da7 100644 --- a/test/contracts/pausable/pausable.spec.ts +++ b/test/contracts/pausable/pausable.spec.ts @@ -41,6 +41,15 @@ describe('pause', () => { expect(await parcelNFT.paused()).to.be.true; }); + + it('should send a Paused event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + expect(await parcelNFT.pause()) + .to.emit(parcelNFT, 'Paused') + .withArgs(INITIALIZER.address); + }); }); describe('unpause', () => { @@ -84,4 +93,15 @@ describe('unpause', () => { expect(await parcelNFT.paused()).to.be.false; }); + + it('should send a Unpaused event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PAUSER_ROLE, INITIALIZER.address); + + await parcelNFT.pause(); + + expect(await parcelNFT.unpause()) + .to.emit(parcelNFT, 'Unpaused') + .withArgs(INITIALIZER.address); + }); }); From d34d1741e291ac37d3e069eab54687f4d3f901d7 Mon Sep 17 00:00:00 2001 From: "Michael D. Norman" Date: Sun, 24 Apr 2022 12:49:32 -0500 Subject: [PATCH 6/6] chore: [#1] added royalty --- contracts/ParcelNFT.sol | 111 ++++++++++++- contracts/common/RoyaltyEventSupport.sol | 53 +++++++ contracts/test/ERC165IdCalc.sol | 26 +++ src/constants/interfaces.ts | 12 ++ test/contracts/access/access.spec.ts | 6 + test/contracts/erc165/interfaceCalc.spec.ts | 56 +++++++ test/contracts/erc721/erc721.ts | 8 + .../{tokenURI => erc721}/tokenURI.spec.ts | 0 test/contracts/royalty/royalty.spec.ts | 150 ++++++++++++++++++ test/helpers/ERC165Helper.ts | 19 +++ 10 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 contracts/common/RoyaltyEventSupport.sol create mode 100644 contracts/test/ERC165IdCalc.sol create mode 100644 src/constants/interfaces.ts create mode 100644 test/contracts/erc165/interfaceCalc.spec.ts create mode 100644 test/contracts/erc721/erc721.ts rename test/contracts/{tokenURI => erc721}/tokenURI.spec.ts (100%) create mode 100644 test/contracts/royalty/royalty.spec.ts create mode 100644 test/helpers/ERC165Helper.ts diff --git a/contracts/ParcelNFT.sol b/contracts/ParcelNFT.sol index 9bd455d..20a4bdf 100644 --- a/contracts/ParcelNFT.sol +++ b/contracts/ParcelNFT.sol @@ -6,10 +6,19 @@ import '@gnus.ai/contracts-upgradeable-diamond/access/AccessControlUpgradeable.s import '@gnus.ai/contracts-upgradeable-diamond/proxy/utils/UUPSUpgradeable.sol'; import '@gnus.ai/contracts-upgradeable-diamond/security/PausableUpgradeable.sol'; import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/extensions/ERC721RoyaltyUpgradeable.sol'; import './ParcelNFTStorage.sol'; +import './common/RoyaltyEventSupport.sol'; import './Roles.sol'; -contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessControlUpgradeable, PausableUpgradeable { +contract ParcelNFT is + UUPSUpgradeable, + ERC721URIStorageUpgradeable, + RoyaltyEventSupport, + ERC721RoyaltyUpgradeable, + AccessControlUpgradeable, + PausableUpgradeable +{ struct InitParams { string name; string symbol; @@ -19,6 +28,8 @@ contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessContro function initialize(InitParams memory initParams) public initializer { __ERC721_init(initParams.name, initParams.symbol); __ERC721URIStorage_init_unchained(); + __ERC2981_init_unchained(); + __ERC721Royalty_init_unchained(); __AccessControl_init_unchained(); __Pausable_init_unchained(); @@ -37,7 +48,7 @@ contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessContro public view virtual - override(AccessControlUpgradeable, ERC721Upgradeable) + override(AccessControlUpgradeable, ERC721RoyaltyUpgradeable, ERC721Upgradeable, ERC2981Upgradeable) returns (bool) { return super.supportsInterface(interfaceId); @@ -54,6 +65,19 @@ contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessContro return ParcelNFTStorage.baseURI(); } + /** + * @dev See {IERC721Metadata-tokenURI}. + */ + function tokenURI(uint256 tokenId) + public + view + virtual + override(ERC721URIStorageUpgradeable, ERC721Upgradeable) + returns (string memory) + { + return super.tokenURI(tokenId); + } + /** * @notice Sets `_tokenURI` as the tokenURI of `tokenId`. * @@ -65,6 +89,85 @@ contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessContro _setTokenURI(tokenId, _tokenURI); } + /** + * @notice Sets the royalty information that all ids in this contract will default to. + * + * Requirements: + * + * - `receiver` cannot be the zero address. + * - `feeNumerator` cannot be greater than the fee denominator. + */ + function setDefaultRoyalty(address receiver, uint96 feeNumerator) external onlyRole(Roles.PARCEL_MANAGER) { + _setDefaultRoyalty(receiver, feeNumerator); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _setDefaultRoyalty(address receiver, uint96 feeNumerator) + internal + virtual + override(RoyaltyEventSupport, ERC2981Upgradeable) + { + super._setDefaultRoyalty(receiver, feeNumerator); + } + + /** + * @notice Removes default royalty information. + */ + function deleteDefaultRoyalty() external onlyRole(Roles.PARCEL_MANAGER) { + _deleteDefaultRoyalty(); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _deleteDefaultRoyalty() internal virtual override(RoyaltyEventSupport, ERC2981Upgradeable) { + super._deleteDefaultRoyalty(); + } + + /** + * @notice Sets the royalty information for a specific token id, overriding the global default. + * + * Requirements: + * + * - `tokenId` must be already minted. + * - `receiver` cannot be the zero address. + * - `feeNumerator` cannot be greater than the fee denominator. + */ + function setTokenRoyalty( + uint256 tokenId, + address receiver, + uint96 feeNumerator + ) external onlyRole(Roles.PARCEL_MANAGER) { + _setTokenRoyalty(tokenId, receiver, feeNumerator); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _setTokenRoyalty( + uint256 tokenId, + address receiver, + uint96 feeNumerator + ) internal virtual override(RoyaltyEventSupport, ERC2981Upgradeable) { + super._setTokenRoyalty(tokenId, receiver, feeNumerator); + } + + /** + * @notice Resets royalty information for the token id back to the global default. + */ + function resetTokenRoyalty(uint256 tokenId) external onlyRole(Roles.PARCEL_MANAGER) { + _resetTokenRoyalty(tokenId); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _resetTokenRoyalty(uint256 tokenId) internal virtual override(RoyaltyEventSupport, ERC2981Upgradeable) { + super._resetTokenRoyalty(tokenId); + } + /** * @notice Triggers stopped state. * @@ -87,6 +190,10 @@ contract ParcelNFT is UUPSUpgradeable, ERC721URIStorageUpgradeable, AccessContro _unpause(); } + function _burn(uint256 tokenId) internal virtual override(ERC721URIStorageUpgradeable, ERC721RoyaltyUpgradeable) { + super._burn(tokenId); + } + // solhint-disable-next-line no-empty-blocks function _authorizeUpgrade(address newImplementation) internal virtual override onlyRole(Roles.UPGRADER) {} } diff --git a/contracts/common/RoyaltyEventSupport.sol b/contracts/common/RoyaltyEventSupport.sol new file mode 100644 index 0000000..64ab773 --- /dev/null +++ b/contracts/common/RoyaltyEventSupport.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.9; + +import '@gnus.ai/contracts-upgradeable-diamond/token/common/ERC2981Upgradeable.sol'; + +abstract contract RoyaltyEventSupport is ERC2981Upgradeable { + /** + * @dev Emitted when the default royalty is updated. + */ + event DefaultRoyaltyChanged(address indexed receiver, uint96 indexed feeNumerator); + + /** + * @dev Emitted when a token royalty is updated. + */ + event TokenRoyaltyChanged(uint256 indexed tokenId, address indexed receiver, uint96 indexed feeNumerator); + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _setDefaultRoyalty(address receiver, uint96 feeNumerator) internal virtual override { + super._setDefaultRoyalty(receiver, feeNumerator); + emit DefaultRoyaltyChanged(receiver, feeNumerator); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _deleteDefaultRoyalty() internal virtual override { + super._deleteDefaultRoyalty(); + emit DefaultRoyaltyChanged(address(0), 0); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _setTokenRoyalty( + uint256 tokenId, + address receiver, + uint96 feeNumerator + ) internal virtual override { + super._setTokenRoyalty(tokenId, receiver, feeNumerator); + emit TokenRoyaltyChanged(tokenId, receiver, feeNumerator); + } + + /** + * @inheritdoc ERC2981Upgradeable + */ + function _resetTokenRoyalty(uint256 tokenId) internal virtual override { + super._resetTokenRoyalty(tokenId); + emit TokenRoyaltyChanged(tokenId, address(0), 0); + } +} diff --git a/contracts/test/ERC165IdCalc.sol b/contracts/test/ERC165IdCalc.sol new file mode 100644 index 0000000..1c83521 --- /dev/null +++ b/contracts/test/ERC165IdCalc.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.9; + +import '@gnus.ai/contracts-upgradeable-diamond/access/IAccessControlUpgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/interfaces/IERC2981Upgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/IERC721Upgradeable.sol'; +import '@gnus.ai/contracts-upgradeable-diamond/token/ERC721/extensions/IERC721MetadataUpgradeable.sol'; + +library ERC165IdCalc { + function calcAccessControlInterfaceId() external pure returns (bytes4) { + return type(IAccessControlUpgradeable).interfaceId; + } + + function calcERC2981InterfaceId() external pure returns (bytes4) { + return type(IERC2981Upgradeable).interfaceId; + } + + function calcERC721InterfaceId() external pure returns (bytes4) { + return type(IERC721Upgradeable).interfaceId; + } + + function calcERC721MetadataInterfaceId() external pure returns (bytes4) { + return type(IERC721MetadataUpgradeable).interfaceId; + } +} diff --git a/src/constants/interfaces.ts b/src/constants/interfaces.ts new file mode 100644 index 0000000..dfcaa17 --- /dev/null +++ b/src/constants/interfaces.ts @@ -0,0 +1,12 @@ +import { BytesLike } from 'ethers'; +import { Hexable } from 'ethers/lib/utils'; +import { toByte4String } from '../utils/fixedBytes'; + +export type Erc165InterfaceId = string; + +export const toErc165InterfaceId = (value: BytesLike | Hexable | number): Erc165InterfaceId => toByte4String(value); + +export const ACCESS_CONTROL_INTERFACE_ID = toErc165InterfaceId(0x7965db0b); +export const ERC2981_INTERFACE_ID = toErc165InterfaceId(0x2a55205a); +export const ERC721_INTERFACE_ID = toErc165InterfaceId(0x80ac58cd); +export const ERC721_METADATA_INTERFACE_ID = toErc165InterfaceId(0x5b5e139f); diff --git a/test/contracts/access/access.spec.ts b/test/contracts/access/access.spec.ts index 7d854e4..62d0e13 100644 --- a/test/contracts/access/access.spec.ts +++ b/test/contracts/access/access.spec.ts @@ -1,10 +1,16 @@ import { expect } from 'chai'; import { ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { ACCESS_CONTROL_INTERFACE_ID } from '../../../src/constants/interfaces'; import { SUPER_ADMIN_ROLE } from '../../../src/constants/roles'; import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; +import { shouldSupportInterface } from '../../helpers/ERC165Helper'; import { ROLE1, ROLE2 } from '../../helpers/Roles'; +describe('supportsInterface', () => { + shouldSupportInterface('IAccessControl', () => createParcelNFT(), ACCESS_CONTROL_INTERFACE_ID); +}); + describe('initialized with zero address', () => { it('should set caller as super admin', async () => { const parcelNFT = await createParcelNFT({ superAdmin: ZERO_ADDRESS }); diff --git a/test/contracts/erc165/interfaceCalc.spec.ts b/test/contracts/erc165/interfaceCalc.spec.ts new file mode 100644 index 0000000..54d395e --- /dev/null +++ b/test/contracts/erc165/interfaceCalc.spec.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai'; +import { + ACCESS_CONTROL_INTERFACE_ID, + Erc165InterfaceId, + ERC2981_INTERFACE_ID, + ERC721_INTERFACE_ID, + ERC721_METADATA_INTERFACE_ID, +} from '../../../src/constants/interfaces'; +import { ERC165IdCalc, ERC165IdCalc__factory } from '../../../types/contracts'; +import { INITIALIZER } from '../../helpers/Accounts'; + +interface InterfaceTest { + name: string; + interfaceId: Erc165InterfaceId; + calcInterfaceId: (idCalc: ERC165IdCalc) => Promise; +} + +const interfaceTests: InterfaceTest[] = [ + { + name: 'AccessControl', + interfaceId: ACCESS_CONTROL_INTERFACE_ID, + calcInterfaceId: (idCalc) => idCalc.calcAccessControlInterfaceId(), + }, + { + name: 'ERC2981', + interfaceId: ERC2981_INTERFACE_ID, + calcInterfaceId: (idCalc) => idCalc.calcERC2981InterfaceId(), + }, + { + name: 'ERC721', + interfaceId: ERC721_INTERFACE_ID, + calcInterfaceId: (idCalc) => idCalc.calcERC721InterfaceId(), + }, + { + name: 'ERC721Metadata', + interfaceId: ERC721_METADATA_INTERFACE_ID, + calcInterfaceId: (idCalc) => idCalc.calcERC721MetadataInterfaceId(), + }, +]; + +describe('calculations', () => { + interfaceTests.forEach(({ name, interfaceId, calcInterfaceId }) => + it(`should match ${name} interface id`, async () => { + const idCalc = await new ERC165IdCalc__factory(INITIALIZER).deploy(); + + expect(await calcInterfaceId(idCalc)).to.eq(interfaceId); + }), + ); + + it('should not have any duplicates', () => { + const interfaceIds = interfaceTests.map((it) => it.interfaceId); + const interfaceSet = new Set(interfaceIds); + + expect(interfaceIds.length).to.eq(interfaceSet.size); + }); +}); diff --git a/test/contracts/erc721/erc721.ts b/test/contracts/erc721/erc721.ts new file mode 100644 index 0000000..5a6480c --- /dev/null +++ b/test/contracts/erc721/erc721.ts @@ -0,0 +1,8 @@ +import { ERC721_INTERFACE_ID, ERC721_METADATA_INTERFACE_ID } from '../../../src/constants/interfaces'; +import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; +import { shouldSupportInterface } from '../../helpers/ERC165Helper'; + +describe('supportsInterface', () => { + shouldSupportInterface('IERC721', () => createParcelNFT(), ERC721_INTERFACE_ID); + shouldSupportInterface('IERC721Metadata', () => createParcelNFT(), ERC721_METADATA_INTERFACE_ID); +}); diff --git a/test/contracts/tokenURI/tokenURI.spec.ts b/test/contracts/erc721/tokenURI.spec.ts similarity index 100% rename from test/contracts/tokenURI/tokenURI.spec.ts rename to test/contracts/erc721/tokenURI.spec.ts diff --git a/test/contracts/royalty/royalty.spec.ts b/test/contracts/royalty/royalty.spec.ts new file mode 100644 index 0000000..7775f89 --- /dev/null +++ b/test/contracts/royalty/royalty.spec.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; +import { BigNumber } from 'ethers'; +import { ZERO_ADDRESS } from '../../../src/constants/accounts'; +import { ERC2981_INTERFACE_ID } from '../../../src/constants/interfaces'; +import { PARCEL_MANAGER_ROLE } from '../../../src/constants/roles'; +import { INITIALIZER, USER1, USER2 } from '../../helpers/Accounts'; +import { createParcelNFT } from '../../helpers/contracts/ParcelNFTHelper'; +import { shouldSupportInterface } from '../../helpers/ERC165Helper'; + +describe('supportsInterface', () => { + shouldSupportInterface('IERC2981', () => createParcelNFT(), ERC2981_INTERFACE_ID); +}); + +describe('setDefaultRoyalty', () => { + it('should update the default royalty', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + + await parcelNFT.connect(USER1).setDefaultRoyalty(USER2.address, 200); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + }); + + it('should fail if not called by parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + + await expect(parcelNFT.connect(USER1).setDefaultRoyalty(USER2.address, 200)).to.be.revertedWith('missing role'); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + }); + + it('should send DefaultRoyaltyChanged event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + expect(await parcelNFT.connect(USER1).setDefaultRoyalty(USER2.address, 200)) + .to.emit(parcelNFT, 'DefaultRoyaltyChanged') + .withArgs(USER2.address, 200); + }); +}); + +describe('deleteDefaultRoyalty', () => { + it('should update the default royalty to 0', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).setDefaultRoyalty(USER2.address, 200); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + + await parcelNFT.connect(USER1).deleteDefaultRoyalty(); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + }); + + it('should fail if not called by parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, INITIALIZER.address); + + await parcelNFT.setDefaultRoyalty(USER2.address, 200); + + await expect(parcelNFT.connect(USER1).deleteDefaultRoyalty()).to.be.revertedWith('missing role'); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + }); + + it('should send DefaultRoyaltyChanged event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).setDefaultRoyalty(USER2.address, 200); + + expect(await parcelNFT.connect(USER1).deleteDefaultRoyalty()) + .to.emit(parcelNFT, 'DefaultRoyaltyChanged') + .withArgs(ZERO_ADDRESS, 0); + }); +}); + +describe('setTokenRoyalty', () => { + it('should update the token royalty', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + + await parcelNFT.connect(USER1).setTokenRoyalty(100, USER2.address, 200); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + expect(await parcelNFT.royaltyInfo(101, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + }); + + it('should fail if not called by parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + + await expect(parcelNFT.connect(USER1).setTokenRoyalty(100, USER2.address, 200)).to.be.revertedWith('missing role'); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + }); + + it('should send TokenRoyaltyChanged event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + expect(await parcelNFT.connect(USER1).setTokenRoyalty(100, USER2.address, 200)) + .to.emit(parcelNFT, 'TokenRoyaltyChanged') + .withArgs(100, USER2.address, 200); + }); +}); + +describe('resetTokenRoyalty', () => { + it('should update the token royalty to 0', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).setTokenRoyalty(100, USER2.address, 200); + await parcelNFT.connect(USER1).setTokenRoyalty(101, USER2.address, 200); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + expect(await parcelNFT.royaltyInfo(101, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + + await parcelNFT.connect(USER1).resetTokenRoyalty(100); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([ZERO_ADDRESS, BigNumber.from(0)]); + expect(await parcelNFT.royaltyInfo(101, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + }); + + it('should fail if not called by parcel manager', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, INITIALIZER.address); + + await parcelNFT.setTokenRoyalty(100, USER2.address, 200); + + await expect(parcelNFT.connect(USER1).resetTokenRoyalty(100)).to.be.revertedWith('missing role'); + + expect(await parcelNFT.royaltyInfo(100, 10000)).to.deep.eq([USER2.address, BigNumber.from(200)]); + }); + + it('should send TokenRoyaltyChanged event', async () => { + const parcelNFT = await createParcelNFT(); + await parcelNFT.grantRole(PARCEL_MANAGER_ROLE, USER1.address); + + await parcelNFT.connect(USER1).setTokenRoyalty(100, USER2.address, 200); + + expect(await parcelNFT.connect(USER1).resetTokenRoyalty(100)) + .to.emit(parcelNFT, 'TokenRoyaltyChanged') + .withArgs(100, ZERO_ADDRESS, 0); + }); +}); diff --git a/test/helpers/ERC165Helper.ts b/test/helpers/ERC165Helper.ts new file mode 100644 index 0000000..5a41361 --- /dev/null +++ b/test/helpers/ERC165Helper.ts @@ -0,0 +1,19 @@ +import { expect } from 'chai'; +import { Contract } from 'ethers'; +import { Erc165InterfaceId } from '../../src/constants/interfaces'; +import { IERC165Upgradeable__factory } from '../../types/contracts'; +import { INITIALIZER } from './Accounts'; + +export const asErc165 = (contract: Contract) => IERC165Upgradeable__factory.connect(contract.address, INITIALIZER); + +export const shouldSupportInterface = ( + interfaceName: string, + create: () => Promise, + interfaceId: Erc165InterfaceId, +) => { + it(`should support ${interfaceName} interface`, async () => { + const obj = asErc165(await create()); + + expect(await obj.supportsInterface(interfaceId)).to.be.true; + }); +};