From 9b215cc54561d034e7629814e8e2e4b522c6c76a Mon Sep 17 00:00:00 2001 From: steven2308 Date: Thu, 29 Feb 2024 14:40:21 -0500 Subject: [PATCH] Adds several utilities to catalog and catalog factory. --- contracts/RMRK/catalog/RMRKCatalog.sol | 7 +- .../implementations/RMRKCatalogFactory.sol | 80 ++++++++++ contracts/implementations/RMRKCatalogImpl.sol | 75 +++++++++ docs/console.md | 12 ++ docs/implementations/RMRKCatalogFactory.md | 147 ++++++++++++++++++ docs/implementations/RMRKCatalogImpl.md | 138 ++++++++++++++++ scripts/deploy-catalog-factory.ts | 22 +++ test/implementations/catalog.ts | 120 ++++++++++++-- test/implementations/catalogFactory.ts | 98 ++++++++++++ 9 files changed, 682 insertions(+), 17 deletions(-) create mode 100644 contracts/implementations/RMRKCatalogFactory.sol create mode 100644 docs/console.md create mode 100644 docs/implementations/RMRKCatalogFactory.md create mode 100644 scripts/deploy-catalog-factory.ts create mode 100644 test/implementations/catalogFactory.ts diff --git a/contracts/RMRK/catalog/RMRKCatalog.sol b/contracts/RMRK/catalog/RMRKCatalog.sol index 37354606..989f2280 100644 --- a/contracts/RMRK/catalog/RMRKCatalog.sol +++ b/contracts/RMRK/catalog/RMRKCatalog.sol @@ -25,10 +25,9 @@ contract RMRKCatalog is IRMRKCatalog { */ mapping(uint64 => bool) private _isEquippableToAll; - uint64[] private _partIds; - - string private _metadataURI; - string private _type; + uint64[] internal _partIds; + string internal _metadataURI; + string internal _type; /** * @notice Used to initialize the Catalog. diff --git a/contracts/implementations/RMRKCatalogFactory.sol b/contracts/implementations/RMRKCatalogFactory.sol new file mode 100644 index 00000000..5887344d --- /dev/null +++ b/contracts/implementations/RMRKCatalogFactory.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: Apache-2.0 + +pragma solidity ^0.8.21; + +import {RMRKCatalogImpl} from "./RMRKCatalogImpl.sol"; + +/** + * @title RMRKCatalogFactory + * @author RMRK team + * @notice Smart contract to deploy catalog implementations and keep track of deployers. + */ +contract RMRKCatalogFactory { + mapping(address deployer => address[] catalogs) private _deployerCatalogs; + + event CatalogDeployed(address indexed deployer, address indexed catalog); + + /** + * @notice Used to deploy a new RMRKCatalog implementation. + * @param metadataURI Base metadata URI of the catalog + * @param type_ The type of the catalog + * @return The address of the deployed catalog + */ + function deployCatalog( + string memory metadataURI, + string memory type_ + ) public returns (address) { + RMRKCatalogImpl catalog = new RMRKCatalogImpl(metadataURI, type_); + _deployerCatalogs[msg.sender].push(address(catalog)); + emit CatalogDeployed(msg.sender, address(catalog)); + return address(catalog); + } + + /** + * @notice Used to get all catalogs deployed by a given deployer. + * @param deployer The address of the deployer + * @return An array of addresses of the catalogs deployed by the deployer + */ + function getDeployerCatalogs( + address deployer + ) public view returns (address[] memory) { + return _deployerCatalogs[deployer]; + } + + /** + * @notice Used to get the total number of catalogs deployed by a given deployer. + * @param deployer The address of the deployer + * @return total The total number of catalogs deployed by the deployer + */ + function getTotalDeployerCatalogs( + address deployer + ) public view returns (uint256 total) { + total = _deployerCatalogs[deployer].length; + } + + /** + * @notice Used to get a catalog deployed by a given deployer at a given index. + * @param deployer The address of the deployer + * @param index The index of the catalog + * @return catalogAddress The address of the catalog + */ + function getDeployerCatalogAtIndex( + address deployer, + uint256 index + ) public view returns (address catalogAddress) { + catalogAddress = _deployerCatalogs[deployer][index]; + } + + /** + * @notice Used to get the last catalog deployed by a given deployer. + * @param deployer The address of the deployer + * @return catalogAddress The address of the last catalog deployed by the deployer + */ + function getLastDeployerCatalog( + address deployer + ) public view returns (address catalogAddress) { + catalogAddress = _deployerCatalogs[deployer][ + _deployerCatalogs[deployer].length - 1 + ]; + } +} diff --git a/contracts/implementations/RMRKCatalogImpl.sol b/contracts/implementations/RMRKCatalogImpl.sol index c57aca87..9056531a 100644 --- a/contracts/implementations/RMRKCatalogImpl.sol +++ b/contracts/implementations/RMRKCatalogImpl.sol @@ -14,6 +14,17 @@ import {RMRKCatalog} from "../RMRK/catalog/RMRKCatalog.sol"; * catalog contract. */ contract RMRKCatalogImpl is OwnableLock, RMRKCatalog { + /** + * @notice From ERC7572 (Draft) Emitted when the contract-level metadata is updated + */ + event ContractURIUpdated(); + + /** + * @notice Emited when the type of the catalog is updated + * @param newType The new type of the catalog + */ + event TypeUpdated(string newType); + /** * @notice Used to initialize the smart contract. * @param metadataURI Base metadata URI of the contract @@ -121,4 +132,68 @@ contract RMRKCatalogImpl is OwnableLock, RMRKCatalog { ) public virtual onlyOwnerOrContributor { _resetEquippableAddresses(partId); } + + /** + * @notice Used to get all the part IDs in the catalog. + * @dev Can get at least 10k parts. Higher limits were not tested. + * @dev It may fail if there are too many parts, in that case use either `getPaginatedPartIds` or `getTotalParts` and `getPartByIndex`. + * @return partIds An array of all the part IDs in the catalog + */ + function getAllPartIds() public view returns (uint64[] memory partIds) { + partIds = _partIds; + } + + /** + * @notice Used to get all the part IDs in the catalog. + * @param offset The offset to start from + * @param limit The maximum number of parts to return + * @return partIds An array of all the part IDs in the catalog + */ + function getPaginatedPartIds( + uint256 offset, + uint256 limit + ) public view returns (uint64[] memory partIds) { + if (offset >= _partIds.length) limit = 0; // Could revert but UI would have to handle it + if (offset + limit > _partIds.length) limit = _partIds.length - offset; + partIds = new uint64[](limit); + for (uint256 i; i < limit; ) { + partIds[i] = _partIds[offset + i]; + unchecked { + ++i; + } + } + } + + /** + * @notice Used to get the total number of parts in the catalog. + * @return totalParts The total number of parts in the catalog + */ + function getTotalParts() public view returns (uint256 totalParts) { + totalParts = _partIds.length; + } + + /** + * @notice Used to get a single `Part` by the index of its `partId`. + * @param index The index of the `partId`. + * @return part The `Part` struct associated with the `partId` at the given index + */ + function getPartByIndex( + uint256 index + ) public view returns (Part memory part) { + part = getPart(_partIds[index]); + } + + function setMetadataURI( + string memory newContractURI + ) public virtual onlyOwnerOrContributor { + _setMetadataURI(newContractURI); + emit ContractURIUpdated(); + } + + function setType( + string memory newType + ) public virtual onlyOwnerOrContributor { + _setType(newType); + emit TypeUpdated(newType); + } } diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 00000000..8bab67a4 --- /dev/null +++ b/docs/console.md @@ -0,0 +1,12 @@ +# console + + + + + + + + + + + diff --git a/docs/implementations/RMRKCatalogFactory.md b/docs/implementations/RMRKCatalogFactory.md new file mode 100644 index 00000000..a01b769e --- /dev/null +++ b/docs/implementations/RMRKCatalogFactory.md @@ -0,0 +1,147 @@ +# RMRKCatalogFactory + +*RMRK team* + +> RMRKCatalogFactory + +Smart contract to deploy catalog implementations and keep track of deployers. + + + +## Methods + +### deployCatalog + +```solidity +function deployCatalog(string metadataURI, string type_) external nonpayable returns (address) +``` + +Used to deploy a new RMRKCatalog implementation. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| metadataURI | string | Base metadata URI of the catalog | +| type_ | string | The type of the catalog | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address | The address of the deployed catalog | + +### getDeployerCatalogAtIndex + +```solidity +function getDeployerCatalogAtIndex(address deployer, uint256 index) external view returns (address catalogAddress) +``` + +Used to get a catalog deployed by a given deployer at a given index. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| deployer | address | The address of the deployer | +| index | uint256 | The index of the catalog | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| catalogAddress | address | The address of the catalog | + +### getDeployerCatalogs + +```solidity +function getDeployerCatalogs(address deployer) external view returns (address[]) +``` + +Used to get all catalogs deployed by a given deployer. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| deployer | address | The address of the deployer | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| _0 | address[] | An array of addresses of the catalogs deployed by the deployer | + +### getLastDeployerCatalog + +```solidity +function getLastDeployerCatalog(address deployer) external view returns (address catalogAddress) +``` + +Used to get the last catalog deployed by a given deployer. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| deployer | address | The address of the deployer | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| catalogAddress | address | The address of the last catalog deployed by the deployer | + +### getTotalDeployerCatalogs + +```solidity +function getTotalDeployerCatalogs(address deployer) external view returns (uint256 total) +``` + +Used to get the total number of catalogs deployed by a given deployer. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| deployer | address | The address of the deployer | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| total | uint256 | The total number of catalogs deployed by the deployer | + + + +## Events + +### CatalogDeployed + +```solidity +event CatalogDeployed(address indexed deployer, address indexed catalog) +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| deployer `indexed` | address | undefined | +| catalog `indexed` | address | undefined | + + + diff --git a/docs/implementations/RMRKCatalogImpl.md b/docs/implementations/RMRKCatalogImpl.md index 1691212b..1a8d44da 100644 --- a/docs/implementations/RMRKCatalogImpl.md +++ b/docs/implementations/RMRKCatalogImpl.md @@ -104,6 +104,23 @@ Used to check if the part is equippable by all addresses. |---|---|---| | isEquippable | bool | The status indicating whether the part with `partId` can be equipped by any address or not | +### getAllPartIds + +```solidity +function getAllPartIds() external view returns (uint64[] partIds) +``` + +Used to get all the part IDs in the catalog. + +*Can get at least 10k parts. Higher limits were not tested.It may fail if there are too many parts, in that case use either `getPaginatedPartIds` or `getTotalParts` and `getPartByIndex`.* + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| partIds | uint64[] | An array of all the part IDs in the catalog | + ### getLock ```solidity @@ -138,6 +155,29 @@ Used to return the metadata URI of the associated Catalog. |---|---|---| | _0 | string | Catalog metadata URI | +### getPaginatedPartIds + +```solidity +function getPaginatedPartIds(uint256 offset, uint256 limit) external view returns (uint64[] partIds) +``` + +Used to get all the part IDs in the catalog. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| offset | uint256 | The offset to start from | +| limit | uint256 | The maximum number of parts to return | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| partIds | uint64[] | An array of all the part IDs in the catalog | + ### getPart ```solidity @@ -160,6 +200,28 @@ Used to retrieve a `Part` with id `partId` |---|---|---| | part | IRMRKCatalog.Part | The `Part` struct associated with given `partId` | +### getPartByIndex + +```solidity +function getPartByIndex(uint256 index) external view returns (struct IRMRKCatalog.Part part) +``` + +Used to get a single `Part` by the index of its `partId`. + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| index | uint256 | The index of the `partId`. | + +#### Returns + +| Name | Type | Description | +|---|---|---| +| part | IRMRKCatalog.Part | The `Part` struct associated with the `partId` at the given index | + ### getParts ```solidity @@ -182,6 +244,23 @@ Used to retrieve multiple parts at the same time. |---|---|---| | parts | IRMRKCatalog.Part[] | An array of `Part` structs associated with given `partIds` | +### getTotalParts + +```solidity +function getTotalParts() external view returns (uint256 totalParts) +``` + +Used to get the total number of parts in the catalog. + + + + +#### Returns + +| Name | Type | Description | +|---|---|---| +| totalParts | uint256 | The total number of parts in the catalog | + ### getType ```solidity @@ -326,6 +405,38 @@ Locks the operation. *Once locked, functions using `notLocked` modifier cannot be executed.Emits ***LockSet*** event.* +### setMetadataURI + +```solidity +function setMetadataURI(string newContractURI) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newContractURI | string | undefined | + +### setType + +```solidity +function setType(string newType) external nonpayable +``` + + + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newType | string | undefined | + ### supportsInterface ```solidity @@ -405,6 +516,17 @@ Event to announce addition of a new part. | equippableAddresses | address[] | An array of addresses that can equip this part | | metadataURI | string | The metadata URI of the part | +### ContractURIUpdated + +```solidity +event ContractURIUpdated() +``` + +From ERC7572 (Draft) Emitted when the contract-level metadata is updated + + + + ### ContributorUpdate ```solidity @@ -483,6 +605,22 @@ Event to announce the overriding of equippable addresses of the part. | partId `indexed` | uint64 | ID of the part whose list of equippable addresses was overwritten | | equippableAddresses | address[] | The new, full, list of addresses that can equip this part | +### TypeUpdated + +```solidity +event TypeUpdated(string newType) +``` + +Emited when the type of the catalog is updated + + + +#### Parameters + +| Name | Type | Description | +|---|---|---| +| newType | string | The new type of the catalog | + ## Errors diff --git a/scripts/deploy-catalog-factory.ts b/scripts/deploy-catalog-factory.ts new file mode 100644 index 00000000..32215c7e --- /dev/null +++ b/scripts/deploy-catalog-factory.ts @@ -0,0 +1,22 @@ +import { ethers, run } from 'hardhat'; +import { sleep } from './utils'; + +async function main() { + const catalogFactoryFactory = await ethers.getContractFactory('RMRKCatalogFactory'); + const catalogFactory = await catalogFactoryFactory.deploy(); + await catalogFactory.waitForDeployment(); + console.log('RMRK Catalog Factory deployed to:', await catalogFactory.getAddress()); + await sleep(1000); + + await run('verify:verify', { + address: await catalogFactory.getAddress(), + constructorArguments: [], + }); +} + +// We recommend this pattern to be able to use async/await everywhere +// and properly handle errors. +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/implementations/catalog.ts b/test/implementations/catalog.ts index 51bd68d4..60ba0291 100644 --- a/test/implementations/catalog.ts +++ b/test/implementations/catalog.ts @@ -16,20 +16,89 @@ async function catalogFixture(): Promise { describe('CatalogImpl', async () => { shouldBehaveLikeCatalog('RMRKCatalogImpl', 'ipfs//:meta', 'misc'); - describe('Permissions', async () => { - let catalog: RMRKCatalogImpl; - let owner: SignerWithAddress; - let contributor: SignerWithAddress; - let other: SignerWithAddress; - const fixedType = 2n; - const partId = 1n; - const partData = { - itemType: fixedType, - z: 0n, - equippable: [], - metadataURI: 'ipfs://metadata', - }; + let catalog: RMRKCatalogImpl; + let owner: SignerWithAddress; + let contributor: SignerWithAddress; + let other: SignerWithAddress; + const fixedType = 2n; + const partId = 1n; + const partData = { + itemType: fixedType, + z: 0n, + equippable: [], + metadataURI: 'ipfs://metadata', + }; + + describe('With added parts', async () => { + const partList = [ + { + partId: 1n, + part: { itemType: fixedType, z: 0n, equippable: [], metadataURI: 'ipfs://metadata1' }, + }, + { + partId: 2n, + part: { itemType: fixedType, z: 0n, equippable: [], metadataURI: 'ipfs://metadata2' }, + }, + { + partId: 3n, + part: { itemType: fixedType, z: 0n, equippable: [], metadataURI: 'ipfs://metadata3' }, + }, + { + partId: 4n, + part: { itemType: fixedType, z: 0n, equippable: [], metadataURI: 'ipfs://metadata4' }, + }, + { + partId: 5n, + part: { itemType: fixedType, z: 0n, equippable: [], metadataURI: 'ipfs://metadata5' }, + }, + ]; + beforeEach(async () => { + catalog = await loadFixture(catalogFixture); + [owner, contributor, other] = await ethers.getSigners(); + await catalog.connect(owner).addPartList(partList); + }); + + it('can get total parts', async function () { + expect(await catalog.getTotalParts()).to.eql(5n); + }); + + it('can get part by index', async function () { + expect(await catalog.getPartByIndex(0)).to.eql([fixedType, 0n, [], 'ipfs://metadata1']); + expect(await catalog.getPartByIndex(4)).to.eql([fixedType, 0n, [], 'ipfs://metadata5']); + }); + + it('can get all part ids', async function () { + expect(await catalog.getAllPartIds()).to.eql([1n, 2n, 3n, 4n, 5n]); + }); + + it('can get paginated part ids', async function () { + expect(await catalog.getPaginatedPartIds(0, 2)).to.eql([1n, 2n]); + expect(await catalog.getPaginatedPartIds(2, 2)).to.eql([3n, 4n]); + expect(await catalog.getPaginatedPartIds(4, 2)).to.eql([5n]); + }); + + it.skip('can get all part ids up to 10k, skipped so tests run faster', async function () { + const partList = Array.from({ length: 10000 }, (_, i) => ({ + partId: BigInt(i + 6), + part: { + itemType: fixedType, + z: 0n, + equippable: [], + metadataURI: `ipfs://metadata${i + 6}`, + }, + })); + const chunkSize = 20; + for (let i = 0; i < partList.length; i += chunkSize) { + await catalog.connect(owner).addPartList(partList.slice(i, i + chunkSize)); + } + expect(await catalog.getAllPartIds()).to.eql( + Array.from({ length: 10005 }, (_, i) => BigInt(i + 1)), + ); + }).timeout(120000); + }); + + describe('Permissions', async () => { beforeEach(async () => { [owner, contributor, other] = await ethers.getSigners(); catalog = await loadFixture(catalogFixture); @@ -83,5 +152,30 @@ describe('CatalogImpl', async () => { await catalog.connect(contributor).addPart({ partId: partId, part: partData }); expect(await catalog.getPart(partId)).to.eql([fixedType, 0n, [], 'ipfs://metadata']); }); + + it('can set metadataURI or type if owner', async function () { + await catalog.connect(owner).setMetadataURI('ipfs://new'); + await catalog.connect(owner).setType('img/png'); + expect(await catalog.getMetadataURI()).to.eql('ipfs://new'); + expect(await catalog.getType()).to.eql('img/png'); + }); + + it('can set metadataURI or type if contributor', async function () { + await catalog.connect(owner).manageContributor(contributor.address, true); + await catalog.connect(contributor).setMetadataURI('ipfs://new'); + await catalog.connect(contributor).setType('img/png'); + expect(await catalog.getMetadataURI()).to.eql('ipfs://new'); + expect(await catalog.getType()).to.eql('img/png'); + }); + + it('cannot set metadataURI nor type if not owner or contributor', async function () { + await expect( + catalog.connect(other).setMetadataURI('ipfs://new'), + ).to.be.revertedWithCustomError(catalog, 'RMRKNotOwnerOrContributor'); + await expect(catalog.connect(other).setType('img/png')).to.be.revertedWithCustomError( + catalog, + 'RMRKNotOwnerOrContributor', + ); + }); }); }); diff --git a/test/implementations/catalogFactory.ts b/test/implementations/catalogFactory.ts new file mode 100644 index 00000000..f874e2aa --- /dev/null +++ b/test/implementations/catalogFactory.ts @@ -0,0 +1,98 @@ +import { ethers } from 'hardhat'; +import { expect } from 'chai'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import { SignerWithAddress } from '@nomicfoundation/hardhat-ethers/signers'; +import { RMRKCatalogFactory } from '../../typechain-types'; + +async function catalogFactoryFixture(): Promise { + const factory = await ethers.getContractFactory('RMRKCatalogFactory'); + const catalogFactory = await factory.deploy(); + await catalogFactory.waitForDeployment(); + + return catalogFactory; +} + +describe('CatalogImpl', async () => { + let catalogFactory: RMRKCatalogFactory; + let deployer1: SignerWithAddress; + let deployer2: SignerWithAddress; + + beforeEach(async () => { + [, deployer1, deployer2] = await ethers.getSigners(); + catalogFactory = await loadFixture(catalogFactoryFixture); + }); + + it('can deploy a new catalog', async () => { + const tx = await catalogFactory + .connect(deployer1) + .deployCatalog('ipfs://catalogMetadata', 'img/jpeg'); + const receipt = await tx.wait(); + const catalogAddress = receipt?.logs?.[0]?.address; + if (!catalogAddress) { + throw new Error('Catalog address not found'); + } + const catalog = await ethers.getContractAt('RMRKCatalogImpl', catalogAddress); + const metadataURI = await catalog.getMetadataURI(); + const mediaType = await catalog.getType(); + + expect(metadataURI).to.equal('ipfs://catalogMetadata'); + expect(mediaType).to.equal('img/jpeg'); + }); + + it('can get catalogs deployed by a deployer', async () => { + const catalogAddress1 = await deployAndGetAddress( + deployer1, + 'ipfs://catalogMetadata1', + 'img/jpeg', + ); + const catalogAddress2 = await deployAndGetAddress( + deployer1, + 'ipfs://catalogMetadata2', + 'img/png', + ); + const catalogAddress3 = await deployAndGetAddress( + deployer2, + 'ipfs://otherDeployerCatalog', + 'img/svg', + ); + + expect(await catalogFactory.getDeployerCatalogs(deployer1.address)).to.deep.equal([ + catalogAddress1, + catalogAddress2, + ]); + expect(await catalogFactory.getDeployerCatalogs(deployer2.address)).to.deep.equal([ + catalogAddress3, + ]); + + expect(await catalogFactory.getLastDeployerCatalog(deployer1.address)).to.equal( + catalogAddress2, + ); + expect(await catalogFactory.getLastDeployerCatalog(deployer2.address)).to.equal( + catalogAddress3, + ); + + expect(await catalogFactory.getTotalDeployerCatalogs(deployer1.address)).to.equal(2); + expect(await catalogFactory.getTotalDeployerCatalogs(deployer2.address)).to.equal(1); + + expect(await catalogFactory.getDeployerCatalogAtIndex(deployer1.address, 0)).to.equal( + catalogAddress1, + ); + expect(await catalogFactory.getDeployerCatalogAtIndex(deployer1.address, 1)).to.equal( + catalogAddress2, + ); + }); + + async function deployAndGetAddress( + deployer: SignerWithAddress, + metadataURI: string, + mediaType: string, + ) { + const tx = await catalogFactory.connect(deployer).deployCatalog(metadataURI, mediaType); + const receipt = await tx.wait(); + const catalogAddress = receipt?.logs?.[0]?.address; + if (!catalogAddress) { + throw new Error('Catalog address not found'); + } + return catalogAddress; + } +});