From fc20f02b7f5e0166e4d6754a5fe7dbb367c82bf8 Mon Sep 17 00:00:00 2001 From: Felipe Mendes Date: Wed, 18 Jan 2023 11:39:44 -0300 Subject: [PATCH] feat: add app access points and libraries clean up (#69) * feat: add mirror mapping and management * test: add mirrors tests * chore: add new functions header comments * feat: add isMirrorVerified function * feat: add list of mirrors to token * feat: add require minted to appMirrors function * chore: update solidity compiler on hardhat config * refactor: add token id to other mirror events * refactor: change from mirror to access point and update its metadata * test: updates tests due to contract changes * refactor: clean up string parser from main contract * refactor: remove wronge requirement comments * refactor: strings library (#71) * refactor: move string parse functions to a library * refactor: remove not used modifier * refactor: move svg generation to library * refactor: remove source from aps * refactor: rename accessPoint function --- contracts/FleekERC721.sol | 268 +++++++++++++++++++++++++------- contracts/util/FleekSVG.sol | 43 +++++ contracts/util/FleekStrings.sol | 67 ++++++++ hardhat.config.ts | 6 +- test/FleekERC721.ts | 240 ++++++++++++++++++++++++++++ 5 files changed, 562 insertions(+), 62 deletions(-) create mode 100644 contracts/util/FleekSVG.sol create mode 100644 contracts/util/FleekStrings.sol diff --git a/contracts/FleekERC721.sol b/contracts/FleekERC721.sol index 956882d..14f4c2e 100644 --- a/contracts/FleekERC721.sol +++ b/contracts/FleekERC721.sol @@ -6,10 +6,13 @@ import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import "@openzeppelin/contracts/utils/Counters.sol"; import "@openzeppelin/contracts/utils/Base64.sol"; import "./FleekAccessControl.sol"; +import "./util/FleekStrings.sol"; contract FleekERC721 is ERC721, FleekAccessControl { - using Strings for uint256; using Counters for Counters.Counter; + using FleekStrings for FleekERC721.App; + using FleekStrings for FleekERC721.AccessPoint; + using FleekStrings for string; event NewBuild(uint256 indexed token, string indexed commitHash, address indexed triggeredBy); event NewTokenName(uint256 indexed token, string indexed name, address indexed triggeredBy); @@ -18,6 +21,27 @@ contract FleekERC721 is ERC721, FleekAccessControl { event NewTokenExternalURL(uint256 indexed token, string indexed externalURL, address indexed triggeredBy); event NewTokenENS(uint256 indexed token, string indexed ENS, address indexed triggeredBy); + event NewAccessPoint(string indexed apName, uint256 indexed tokenId, address indexed owner); + event RemoveAccessPoint(string indexed apName, uint256 indexed tokenId, address indexed owner); + event ChangeAccessPointScore( + string indexed apName, + uint256 indexed tokenId, + uint256 score, + address indexed triggeredBy + ); + event ChangeAccessPointNameVerify( + string indexed apName, + uint256 tokenId, + bool indexed verified, + address indexed triggeredBy + ); + event ChangeAccessPointContentVerify( + string indexed apName, + uint256 tokenId, + bool indexed verified, + address indexed triggeredBy + ); + /** * The properties are stored as string to keep consistency with * other token contracts, we might consider changing for bytes32 @@ -30,6 +54,7 @@ contract FleekERC721 is ERC721, FleekAccessControl { string ENS; // ENS ID uint256 currentBuild; // The current build number (Increments by one with each change, starts at zero) mapping(uint256 => Build) builds; // Mapping to build details for each build number + string[] accessPoints; // List of app AccessPoint } /** @@ -40,8 +65,21 @@ contract FleekERC721 is ERC721, FleekAccessControl { string gitRepository; } - Counters.Counter private _tokenIds; + /** + * The stored data for each AccessPoint. + */ + struct AccessPoint { + uint256 tokenId; + uint256 index; + uint256 score; + bool contentVerified; + bool nameVerified; + address owner; + } + + Counters.Counter private _appIds; mapping(uint256 => App) private _apps; + mapping(string => AccessPoint) private _accessPoints; /** * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. @@ -49,47 +87,13 @@ contract FleekERC721 is ERC721, FleekAccessControl { constructor(string memory _name, string memory _symbol) ERC721(_name, _symbol) {} /** - * @dev Checks if msg.sender has the role of tokenOwner for a certain tokenId. + * @dev Checks if the AccessPoint exists. */ - modifier requireTokenOwner(uint256 tokenId) { - require(msg.sender == ownerOf(tokenId), "FleekERC721: must be token owner"); + modifier requireAP(string memory apName) { + require(_accessPoints[apName].owner != address(0), "FleekERC721: invalid AP"); _; } - /** - * @dev Generates a SVG image. - */ - function _generateSVG(string memory name, string memory ENS) internal view returns (string memory) { - return ( - string( - abi.encodePacked( - _baseURI(), - Base64.encode( - abi.encodePacked( - '', - "", - "", - '', - '', - "", - '', - 'Fleek NFAs', - "", - '', - '', - name, - '', - ENS, - "", - "", - "" - ) - ) - ) - ) - ); - } - /** * @dev Mints a token and returns a tokenId. * @@ -109,9 +113,9 @@ contract FleekERC721 is ERC721, FleekAccessControl { string memory commitHash, string memory gitRepository ) public payable requireCollectionRole(Roles.Owner) returns (uint256) { - uint256 tokenId = _tokenIds.current(); + uint256 tokenId = _appIds.current(); _mint(to, tokenId); - _tokenIds.increment(); + _appIds.increment(); App storage app = _apps[tokenId]; app.name = name; @@ -122,6 +126,7 @@ contract FleekERC721 is ERC721, FleekAccessControl { // The mint interaction is considered to be the first build of the site. Updates from now on all increment the currentBuild by one and update the mapping. app.currentBuild = 0; app.builds[0] = Build(commitHash, gitRepository); + app.accessPoints = new string[](0); return tokenId; } @@ -141,24 +146,7 @@ contract FleekERC721 is ERC721, FleekAccessControl { address owner = ownerOf(tokenId); App storage app = _apps[tokenId]; - // prettier-ignore - bytes memory dataURI = abi.encodePacked( - '{', - '"name":"', app.name, '",', - '"description":"', app.description, '",', - '"owner":"', Strings.toHexString(uint160(owner), 20), '",', - '"external_url":"', app.externalURL, '",', - '"image":"', _generateSVG(app.name, app.ENS), '",', - '"attributes": [', - '{"trait_type": "ENS", "value":"', app.ENS,'"},', - '{"trait_type": "Commit Hash", "value":"', app.builds[app.currentBuild].commitHash,'"},', - '{"trait_type": "Repository", "value":"', app.builds[app.currentBuild].gitRepository,'"},', - '{"trait_type": "Version", "value":"', Strings.toString(app.currentBuild),'"}', - ']', - '}' - ); - - return string(abi.encodePacked(_baseURI(), Base64.encode((dataURI)))); + return string(abi.encodePacked(_baseURI(), app.toString(owner).toBase64())); } /** @@ -278,6 +266,168 @@ contract FleekERC721 is ERC721, FleekAccessControl { emit NewTokenDescription(tokenId, _tokenDescription, msg.sender); } + /** + * @dev Add a new AccessPoint register for an app token. + * The AP name should be a DNS or ENS url and it should be unique. + * Anyone can add an AP but it should requires a payment. + * + * May emit a {NewAccessPoint} event. + * + * Requirements: + * + * - the tokenId must be minted and valid. + * + * IMPORTANT: The payment is not set yet + */ + function addAccessPoint(uint256 tokenId, string memory apName) public payable { + // require(msg.value == 0.1 ether, "You need to pay at least 0.1 ETH"); // TODO: define a minimum price + _requireMinted(tokenId); + require(_accessPoints[apName].owner == address(0), "FleekERC721: AP already exists"); + + _accessPoints[apName] = AccessPoint(tokenId, _apps[tokenId].accessPoints.length, 0, false, false, msg.sender); + _apps[tokenId].accessPoints.push(apName); + + emit NewAccessPoint(apName, tokenId, msg.sender); + } + + /** + * @dev Remove an AccessPoint registry for an app token. + * It will also remove the AP from the app token APs list. + * + * May emit a {RemoveAccessPoint} event. + * + * Requirements: + * + * - the AP must exist. + * - must be called by the AP owner. + */ + function removeAccessPoint(string memory apName) public requireAP(apName) { + require(msg.sender == _accessPoints[apName].owner, "FleekERC721: must be AP owner"); + uint256 tokenId = _accessPoints[apName].tokenId; + App storage _app = _apps[tokenId]; + + // the index of the AP to remove + uint256 indexToRemove = _accessPoints[apName].index; + + // the last item is reposited in the index to remove + string memory lastAP = _app.accessPoints[_app.accessPoints.length - 1]; + _app.accessPoints[indexToRemove] = lastAP; + _accessPoints[lastAP].index = indexToRemove; + + // remove the last item + _app.accessPoints.pop(); + + delete _accessPoints[apName]; + emit RemoveAccessPoint(apName, tokenId, msg.sender); + } + + /** + * @dev A view function to gether information about an AccessPoint. + * It returns a JSON string representing the AccessPoint information. + * + * Requirements: + * + * - the AP must exist. + * + */ + function getAccessPointJSON(string memory apName) public view requireAP(apName) returns (string memory) { + AccessPoint storage _ap = _accessPoints[apName]; + return _ap.toString(); + } + + /** + * @dev A view function to check if a AccessPoint is verified. + * + * Requirements: + * + * - the AP must exist. + * + */ + function isAccessPointNameVerified(string memory apName) public view requireAP(apName) returns (bool) { + return _accessPoints[apName].nameVerified; + } + + /** + * @dev Increases the score of a AccessPoint registry. + * + * May emit a {ChangeAccessPointScore} event. + * + * Requirements: + * + * - the AP must exist. + * + */ + function increaseAccessPointScore(string memory apName) public requireAP(apName) { + _accessPoints[apName].score++; + emit ChangeAccessPointScore(apName, _accessPoints[apName].tokenId, _accessPoints[apName].score, msg.sender); + } + + /** + * @dev Decreases the score of a AccessPoint registry if is greater than 0. + * + * May emit a {ChangeAccessPointScore} event. + * + * Requirements: + * + * - the AP must exist. + * + */ + function decreaseAccessPointScore(string memory apName) public requireAP(apName) { + require(_accessPoints[apName].score > 0, "FleekERC721: score cant be lower"); + _accessPoints[apName].score--; + emit ChangeAccessPointScore(apName, _accessPoints[apName].tokenId, _accessPoints[apName].score, msg.sender); + } + + /** + * @dev Set the content verification of a AccessPoint registry. + * + * May emit a {ChangeAccessPointContentVerify} event. + * + * Requirements: + * + * - the AP must exist. + * - the sender must have the token controller role. + * + */ + function setAccessPointContentVerify( + string memory apName, + bool verified + ) public requireAP(apName) requireTokenRole(_accessPoints[apName].tokenId, Roles.Controller) { + _accessPoints[apName].contentVerified = verified; + emit ChangeAccessPointContentVerify(apName, _accessPoints[apName].tokenId, verified, msg.sender); + } + + /** + * @dev Set the name verification of a AccessPoint registry. + * + * May emit a {ChangeAccessPointNameVerify} event. + * + * Requirements: + * + * - the AP must exist. + * - the sender must have the token controller role. + * + */ + function setAccessPointNameVerify( + string memory apName, + bool verified + ) public requireAP(apName) requireTokenRole(_accessPoints[apName].tokenId, Roles.Controller) { + _accessPoints[apName].nameVerified = verified; + emit ChangeAccessPointNameVerify(apName, _accessPoints[apName].tokenId, verified, msg.sender); + } + + /** + * @dev A view function to gether the list of mirrros for a given app. + * + * Requirements: + * - the tokenId must be minted and valid. + * + */ + function appAccessPoints(uint256 tokenId) public view returns (string[] memory) { + _requireMinted(tokenId); + return _apps[tokenId].accessPoints; + } + /** * @dev Adds a new build to a minted `tokenId`'s builds mapping. * diff --git a/contracts/util/FleekSVG.sol b/contracts/util/FleekSVG.sol new file mode 100644 index 0000000..9cac338 --- /dev/null +++ b/contracts/util/FleekSVG.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.7; + +import "../FleekERC721.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; + +library FleekSVG { + /** + * @dev Generates a SVG image. + */ + function generateBase64(string memory name, string memory ENS) internal pure returns (string memory) { + return ( + string( + abi.encodePacked( + "data:application/json;base64,", + Base64.encode( + abi.encodePacked( + '', + "", + "", + '', + '', + "", + '', + 'Fleek NFAs', + "", + '', + '', + name, + '', + ENS, + "", + "", + "" + ) + ) + ) + ) + ); + } +} diff --git a/contracts/util/FleekStrings.sol b/contracts/util/FleekStrings.sol new file mode 100644 index 0000000..b34bba5 --- /dev/null +++ b/contracts/util/FleekStrings.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.7; + +import "../FleekERC721.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; +import "./FleekSVG.sol"; + +library FleekStrings { + using Strings for uint256; + using Strings for uint160; + using FleekStrings for bool; + + /** + * @dev Converts a boolean value to a string. + */ + function toString(bool _bool) internal pure returns (string memory) { + return _bool ? "true" : "false"; + } + + /** + * @dev Converts a string to a base64 string. + */ + function toBase64(string memory str) internal pure returns (string memory) { + return Base64.encode(bytes(str)); + } + + /** + * @dev Converts FleekERC721.App to a JSON string. + * It requires to receive owner address as a parameter. + */ + function toString(FleekERC721.App storage app, address owner) internal view returns (string memory) { + // prettier-ignore + return string(abi.encodePacked( + '{', + '"name":"', app.name, '",', + '"description":"', app.description, '",', + '"owner":"', uint160(owner).toHexString(20), '",', + '"external_url":"', app.externalURL, '",', + '"image":"', FleekSVG.generateBase64(app.name, app.ENS), '",', + '"attributes": [', + '{"trait_type": "ENS", "value":"', app.ENS,'"},', + '{"trait_type": "Commit Hash", "value":"', app.builds[app.currentBuild].commitHash,'"},', + '{"trait_type": "Repository", "value":"', app.builds[app.currentBuild].gitRepository,'"},', + '{"trait_type": "Version", "value":"', app.currentBuild.toString(),'"}', + ']', + '}' + )); + } + + /** + * @dev Converts FleekERC721.AccessPoint to a JSON string. + */ + function toString(FleekERC721.AccessPoint storage ap) internal view returns (string memory) { + // prettier-ignore + return string(abi.encodePacked( + "{", + '"tokenId":', ap.tokenId.toString(), ",", + '"score":', ap.score.toString(), ",", + '"nameVerified":', ap.nameVerified.toString(), ",", + '"contentVerified":', ap.contentVerified.toString(), ",", + '"owner":"', uint160(ap.owner).toHexString(20), '"', + "}" + )); + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index d8bf1ac..1f9060d 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -52,16 +52,16 @@ const config: HardhatUserConfig = { }, }, solidity: { - version: '0.8.7', + version: '0.8.12', settings: { optimizer: { enabled: true, runs: 200, details: { - yul: false, + yul: true, }, }, - viaIR: false, + viaIR: true, }, }, mocha: { diff --git a/test/FleekERC721.ts b/test/FleekERC721.ts index 482763a..d59a391 100644 --- a/test/FleekERC721.ts +++ b/test/FleekERC721.ts @@ -600,4 +600,244 @@ describe('FleekERC721', () => { .withArgs(ROLES.CONTROLLER, otherAccount.address, owner.address); }); }); + + describe('AccessPoints', () => { + let tokenId: number; + let fixture: Awaited>; + + const getDefaultAddParams = () => [tokenId, 'accesspoint.com']; + + beforeEach(async () => { + fixture = await loadFixture(defaultFixture); + const { contract } = fixture; + + const response = await contract.mint( + fixture.owner.address, + MINT_PARAMS.name, + MINT_PARAMS.description, + MINT_PARAMS.externalUrl, + MINT_PARAMS.ens, + MINT_PARAMS.commitHash, + MINT_PARAMS.gitRepository + ); + + tokenId = response.value.toNumber(); + }); + + it('should add an AP', async () => { + const { contract, owner } = fixture; + + await expect(contract.addAccessPoint(...getDefaultAddParams())) + .to.emit(contract, 'NewAccessPoint') + .withArgs('accesspoint.com', tokenId, owner.address); + + expect(await contract.appAccessPoints(tokenId)).eql(['accesspoint.com']); + }); + + it('should return a AP json object', async () => { + const { contract, owner } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp).to.eql({ + tokenId, + score: 0, + owner: owner.address.toLowerCase(), + contentVerified: false, + nameVerified: false, + }); + }); + + it('should revert if AP does not exist', async () => { + const { contract } = fixture; + + await expect( + contract.getAccessPointJSON('accesspoint.com') + ).to.be.revertedWith('FleekERC721: invalid AP'); + }); + + it('should increase the AP score', async () => { + const { contract, owner } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await contract.increaseAccessPointScore('accesspoint.com'); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp).to.eql({ + tokenId, + score: 1, + owner: owner.address.toLowerCase(), + contentVerified: false, + nameVerified: false, + }); + }); + + it('should decrease the AP score', async () => { + const { contract, owner } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await contract.increaseAccessPointScore('accesspoint.com'); + await contract.increaseAccessPointScore('accesspoint.com'); + await contract.decreaseAccessPointScore('accesspoint.com'); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp).to.eql({ + tokenId, + score: 1, + owner: owner.address.toLowerCase(), + contentVerified: false, + nameVerified: false, + }); + }); + + it('should allow anyone to change AP score', async () => { + const { contract, otherAccount } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + await contract.increaseAccessPointScore('accesspoint.com'); + await contract + .connect(otherAccount) + .increaseAccessPointScore('accesspoint.com'); + }); + + it('should remove an AP', async () => { + const { contract, owner } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await expect(contract.removeAccessPoint('accesspoint.com')) + .to.emit(contract, 'RemoveAccessPoint') + .withArgs('accesspoint.com', tokenId, owner.address); + + expect(await contract.appAccessPoints(tokenId)).eql([]); + }); + + it('should allow only AP owner to remove it', async () => { + const { contract, otherAccount } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await expect( + contract.connect(otherAccount).removeAccessPoint('accesspoint.com') + ).to.be.revertedWith('FleekERC721: must be AP owner'); + }); + + it('should not be allowed to add the same AP more than once', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await expect( + contract.addAccessPoint(...getDefaultAddParams()) + ).to.be.revertedWith('FleekERC721: AP already exists'); + }); + + it('should change "contentVerified" to true', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await contract.setAccessPointContentVerify('accesspoint.com', true); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp.contentVerified).to.be.true; + }); + + it('should change "contentVerified" to false', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + const beforeAp = await contract.getAccessPointJSON('accesspoint.com'); + const beforeParsedAp = JSON.parse(beforeAp); + expect(beforeParsedAp.contentVerified).to.be.false; + + await contract.setAccessPointContentVerify('accesspoint.com', true); + await contract.setAccessPointContentVerify('accesspoint.com', false); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp.contentVerified).to.be.false; + }); + + it('should change "nameVerified" to true', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + await contract.setAccessPointNameVerify('accesspoint.com', true); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp.nameVerified).to.be.true; + }); + + it('should change "nameVerified" to false', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(...getDefaultAddParams()); + + const beforeAp = await contract.getAccessPointJSON('accesspoint.com'); + const beforeParsedAp = JSON.parse(beforeAp); + expect(beforeParsedAp.nameVerified).to.be.false; + + await contract.setAccessPointNameVerify('accesspoint.com', true); + await contract.setAccessPointNameVerify('accesspoint.com', false); + + const ap = await contract.getAccessPointJSON('accesspoint.com'); + const parsedAp = JSON.parse(ap); + + expect(parsedAp.nameVerified).to.be.false; + }); + + it('should get a list of added APs for an app', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(tokenId, 'accesspoint1.com'); + await contract.addAccessPoint(tokenId, 'accesspoint2.com'); + await contract.addAccessPoint(tokenId, 'accesspoint3.com'); + await contract.addAccessPoint(tokenId, 'accesspoint4.com'); + + const aps = await contract.appAccessPoints(tokenId); + + expect(aps).to.eql([ + 'accesspoint1.com', + 'accesspoint2.com', + 'accesspoint3.com', + 'accesspoint4.com', + ]); + }); + + it('should get a list of added APs for an app after removing one', async () => { + const { contract } = fixture; + + await contract.addAccessPoint(tokenId, 'accesspoint1.com'); + await contract.addAccessPoint(tokenId, 'accesspoint2.com'); + await contract.addAccessPoint(tokenId, 'accesspoint3.com'); + await contract.addAccessPoint(tokenId, 'accesspoint4.com'); + + await contract.removeAccessPoint('accesspoint2.com'); + + const aps = await contract.appAccessPoints(tokenId); + + expect(aps).to.eql([ + 'accesspoint1.com', + 'accesspoint4.com', + 'accesspoint3.com', + ]); + }); + }); });