├── .commitlintrc.yml ├── .czrc ├── .editorconfig ├── .env.example ├── .env.github ├── .eslintignore ├── .eslintrc.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .gitpod.yml ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .openzeppelin └── goerli.json ├── .prettierignore ├── .prettierrc.yml ├── .solcover.js ├── .solhint.json ├── .solhintignore ├── .vscode ├── extensions.json └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── LICENSE.md ├── README.md ├── contracts ├── ERC3525SlotEnumerableUpgradeable.sol ├── ERC3525Upgradeable.sol ├── HyperCertMetadata.sol ├── HyperCertMinter.sol ├── HyperCertSVG.sol ├── interfaces │ ├── IERC3525MetadataUpgradeable.sol │ ├── IERC3525Receiver.sol │ ├── IERC3525SlotApprovableUpgradeable.sol │ ├── IERC3525SlotEnumerableUpgradeable.sol │ ├── IERC3525Upgradeable.sol │ └── IHyperCertMetadata.sol ├── lib │ ├── DateTime.sol │ └── strings.sol ├── mocks │ ├── ERC3525_Testing.sol │ └── HyperCertMinterUpgrade.sol └── utils │ ├── ArraysUpgradeable.sol │ └── StringsExtensions.sol ├── deploy ├── 000_HyperCertSVG_Init .ts ├── 001_HyperCertMetadata_Init.ts ├── 002_HyperCertMinter_Init.ts ├── 003_Upgrade_SVG.ts ├── 004_UpgradeMetadata.ts ├── 005_Upgrade_HyperCertMinter.ts ├── 999_Init_Config.ts └── ___ERC3525_Testing.ts ├── deployments └── goerli │ ├── .chainId │ ├── HyperCertMetadata.json │ ├── HyperCertMinter.json │ ├── HyperCertSVG.json │ └── solcInputs │ ├── 29ce5052ce8ebf50caecb28ecaa24f1e.json │ ├── 2de26a43eb589ba91ecd57267349fc8d.json │ ├── 6106fb8b09871f51305f5d7bc092e22b.json │ ├── 95e2209e8476ef5988fecdb3875a02cc.json │ ├── a5d3b67692c1c40adc48188f88870e1b.json │ ├── a66236af90e32afc067156563307701e.json │ ├── c0965906fe79f2efacd4ee0ff3377c18.json │ └── e162cb740af530e8d848e02375987c67.json ├── docs └── index.md ├── examples └── hypercert_metadata.json ├── hardhat.config.ts ├── package.json ├── src ├── util │ └── wellKnown.ts └── xsd │ ├── namespace.xsd │ ├── svg.xsd │ └── xlink.xsd ├── test.html ├── test ├── erc3525 │ ├── ERC3525.allowances.ts │ ├── ERC3525.approvals.ts │ ├── ERC3525.burn.ts │ ├── ERC3525.mint.ts │ ├── ERC3525.miscellaneous.ts │ ├── ERC3525.transfer.ts │ └── ERC3525.ts ├── hypercert_metadata │ └── HyperCertMetadata.ts ├── hypercert_minter │ ├── HyperCertMinter.burning.ts │ ├── HyperCertMinter.integration.ts │ ├── HyperCertMinter.minting.ts │ ├── HyperCertMinter.rights.ts │ ├── HyperCertMinter.scopes.ts │ ├── HyperCertMinter.split.merge.ts │ ├── HyperCertMinter.ts │ └── HyperCertMinter.upgrade.ts ├── hypercert_svg │ ├── HypercertSVG.ts │ └── HypercertSVG.ts~9f761327f106ce841b6aa7856e89e0a7557b823a ├── setup.ts ├── utils.ts └── wellKnown.ts ├── tsconfig.json └── yarn.lock /.commitlintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - "@commitlint/config-conventional" 3 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # All files 7 | [*] 8 | charset = utf-8 9 | end_of_line = lf 10 | indent_size = 2 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | [*.sol] 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | INFURA_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 2 | MNEMONIC="here is where your twelve words mnemonic should be put my friend" 3 | 4 | # Block explorer API keys 5 | ARBISCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 6 | BSCSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 7 | ETHERSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 8 | OPTIMISM_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 9 | POLYGONSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 10 | SNOWTRACE_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 11 | -------------------------------------------------------------------------------- /.env.github: -------------------------------------------------------------------------------- 1 | INFURA_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 2 | MNEMONIC="lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor" 3 | 4 | # Block explorer API keys 5 | ARBISCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 6 | BSCSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 7 | ETHERSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 8 | OPTIMISM_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 9 | POLYGONSCAN_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 10 | SNOWTRACE_API_KEY="zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .yarn/ 3 | **/.coverage_artifacts 4 | **/.coverage_cache 5 | **/.coverage_contracts 6 | **/artifacts 7 | **/build 8 | **/cache 9 | **/coverage 10 | **/dist 11 | **/node_modules 12 | **/types 13 | 14 | # files 15 | *.env 16 | *.log 17 | .pnp.* 18 | coverage.json 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: 2 | - "eslint:recommended" 3 | - "plugin:@typescript-eslint/eslint-recommended" 4 | - "plugin:@typescript-eslint/recommended" 5 | - "prettier" 6 | parser: "@typescript-eslint/parser" 7 | parserOptions: 8 | project: "tsconfig.json" 9 | plugins: 10 | - "@typescript-eslint" 11 | root: true 12 | rules: 13 | "@typescript-eslint/no-floating-promises": 14 | - error 15 | - ignoreIIFE: true 16 | ignoreVoid: true 17 | "@typescript-eslint/no-inferrable-types": "off" 18 | "@typescript-eslint/no-unused-vars": 19 | - error 20 | - argsIgnorePattern: "_" 21 | varsIgnorePattern: "_" 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "CI" 2 | 3 | # env: 4 | # INFURA_API_KEY: ${{ secrets.INFURA_API_KEY }} 5 | # MNEMONIC: ${{ secrets.MNEMONIC }} 6 | 7 | on: 8 | pull_request: 9 | branches: 10 | - "main" 11 | push: 12 | branches: 13 | - "main" 14 | 15 | jobs: 16 | ci: 17 | runs-on: "ubuntu-latest" 18 | steps: 19 | - name: "Check out the repo" 20 | uses: "actions/checkout@v3" 21 | 22 | - name: "Install Node.js" 23 | uses: "actions/setup-node@v3" 24 | with: 25 | cache: "yarn" 26 | node-version: "16" 27 | 28 | - name: "Copy config" 29 | run: | 30 | mv ./.env.github .env 31 | echo "::debug::$(less .env)" 32 | 33 | - name: "Install the dependencies" 34 | run: "yarn install --immutable" 35 | 36 | - name: "Lint the code" 37 | run: "yarn lint" 38 | 39 | - name: "Add lint summary" 40 | run: | 41 | echo "## Lint results" >> $GITHUB_STEP_SUMMARY 42 | echo "✅ Passed" >> $GITHUB_STEP_SUMMARY 43 | 44 | - name: "Compile the contracts and generate the TypeChain bindings" 45 | run: "yarn typechain" 46 | 47 | - name: "Test the contracts and generate the coverage report" 48 | run: "yarn coverage" 49 | 50 | - name: "Add test summary" 51 | run: | 52 | echo "## Test results" >> $GITHUB_STEP_SUMMARY 53 | echo "✅ Passed" >> $GITHUB_STEP_SUMMARY 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .yarn/* 3 | !.yarn/patches 4 | !.yarn/releases 5 | !.yarn/plugins 6 | !.yarn/sdks 7 | !.yarn/versions 8 | **/artifacts 9 | **/build 10 | **/cache 11 | **/coverage 12 | **/.coverage_artifacts 13 | **/.coverage_cache 14 | **/.coverage_contracts 15 | **/dist 16 | **/node_modules 17 | **/src/types 18 | **/deployments/localhost 19 | 20 | # files 21 | *.env 22 | *.log 23 | .pnp.* 24 | coverage.json 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | **/.openzeppelin/*-31337.json 29 | .DS_Store 30 | /.idea 31 | test*.svg 32 | 33 | deployments/goerli/HyperCertMetadata.json 34 | deployments/goerli/HyperCertMinter.json 35 | deployments/goerli/HyperCertSVG.json 36 | test/hypercert_minter/HypercertMinter.split.merge.ts 37 | test/hypercert_svg/HypercertSVG.ts 38 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: "gitpod/workspace-node:latest" 2 | 3 | tasks: 4 | - init: "yarn install" 5 | 6 | vscode: 7 | extensions: 8 | - "esbenp.prettier-vscode" 9 | - "NomicFoundation.hardhat-solidity" 10 | - "ritwickdey.LiveServer" 11 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn dlx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn dlx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,json,md,sol,ts,yml}": [ 3 | "prettier --config ./.prettierrc.yml --write" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .yarn/ 3 | **/.coverage_artifacts 4 | **/.coverage_cache 5 | **/.coverage_contracts 6 | **/artifacts 7 | **/build 8 | **/cache 9 | **/coverage 10 | **/dist 11 | **/node_modules 12 | **/types 13 | **/deployments 14 | **/.openzeppelin 15 | 16 | # files 17 | *.env 18 | *.log 19 | .pnp.* 20 | coverage.json 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | endOfLine: auto 4 | printWidth: 120 5 | singleQuote: false 6 | tabWidth: 2 7 | trailingComma: all 8 | 9 | overrides: 10 | - files: "*.sol" 11 | options: 12 | compiler: "0.8.15" 13 | tabWidth: 4 14 | - files: "*.ts" 15 | options: 16 | importOrder: ["", "^[./]"] 17 | importOrderParserPlugins: ["typescript"] 18 | importOrderSeparation: true 19 | importOrderSortSpecifiers: true 20 | -------------------------------------------------------------------------------- /.solcover.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | istanbulReporter: ["html", "lcov"], 3 | providerOptions: { 4 | mnemonic: process.env.MNEMONIC, 5 | }, 6 | skipFiles: ["test", "mocks", "lib"], 7 | }; 8 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "code-complexity": ["error", 8], 6 | "compiler-version": ["error", ">=0.8.4"], 7 | "func-visibility": ["error", { "ignoreConstructors": true }], 8 | "max-line-length": ["error", 120], 9 | "not-rely-on-time": "off", 10 | "prettier/prettier": [ 11 | "error", 12 | { 13 | "endOfLine": "auto" 14 | } 15 | ], 16 | "quotes": "off", 17 | "reason-string": ["warn", { "maxLength": 64 }] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.solhintignore: -------------------------------------------------------------------------------- 1 | # directories 2 | **/artifacts 3 | **/node_modules 4 | **/mocks 5 | **/lib 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "NomicFoundation.hardhat-solidity"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[markdown]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[solidity]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[typescript]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "editor.formatOnSave": true, 15 | "liveServer.settings.root": "/coverage" 16 | } 17 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: ".yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs" 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 8 | 9 | checksumBehavior: "update" 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Paul Razvan Berg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **DEPRECATED** - Please refer to [https://github.com/Network-Goods/hypercerts](https://github.com/Network-Goods/hypercerts) for the latest 2 | 3 | --- 4 | 5 | # hypercerts-protocol [![Github Actions][gha-badge]][gha] [![Hardhat][hardhat-badge]][hardhat] [![License: MIT][license-badge]][license] 6 | 7 | [gha]: https://github.com/paulrberg/hardhat-template/actions 8 | [gha-badge]: https://github.com/paulrberg/hardhat-template/actions/workflows/ci.yml/badge.svg 9 | [hardhat]: https://hardhat.org/ 10 | [hardhat-badge]: https://img.shields.io/badge/Built%20with-Hardhat-FFDB1C.svg 11 | [license]: https://opensource.org/licenses/MIT 12 | [license-badge]: https://img.shields.io/badge/License-MIT-blue.svg 13 | 14 | A Hardhat-based template for developing Solidity smart contracts, with sensible defaults. 15 | 16 | - [Hardhat](https://github.com/nomiclabs/hardhat): compile, run and test smart contracts 17 | - [TypeChain](https://github.com/ethereum-ts/TypeChain): generate TypeScript bindings for smart contracts 18 | - [Ethers](https://github.com/ethers-io/ethers.js/): renowned Ethereum library and wallet implementation 19 | - [Solhint](https://github.com/protofire/solhint): code linter 20 | - [Solcover](https://github.com/sc-forks/solidity-coverage): code coverage 21 | - [Prettier Plugin Solidity](https://github.com/prettier-solidity/prettier-plugin-solidity): code formatter 22 | 23 | ## Usage 24 | 25 | ### Pre Requisites 26 | 27 | Before being able to run any command, you need to create a `.env` file and set a BIP-39 compatible mnemonic as an environment 28 | variable. You can follow the example in `.env.example`. If you don't already have a mnemonic, you can use this [website](https://iancoleman.io/bip39/) to generate one. 29 | 30 | Then, proceed with installing dependencies: 31 | 32 | > NOTE: Make sure to use Node 16. 33 | 34 | ```sh 35 | $ yarn install 36 | ``` 37 | 38 | ### Run local 39 | 40 | ```sh 41 | $ yarn hardhat node 42 | ``` 43 | 44 | ### Compile 45 | 46 | Compile the smart contracts with Hardhat: 47 | 48 | ```sh 49 | $ yarn compile 50 | ``` 51 | 52 | ### TypeChain 53 | 54 | Compile the smart contracts and generate TypeChain bindings: 55 | 56 | ```sh 57 | $ yarn typechain 58 | ``` 59 | 60 | ### Test 61 | 62 | Run the tests with Hardhat: 63 | 64 | ```sh 65 | $ yarn test 66 | ``` 67 | 68 | ### Lint Solidity 69 | 70 | Lint the Solidity code: 71 | 72 | ```sh 73 | $ yarn lint:sol 74 | ``` 75 | 76 | ### Lint TypeScript 77 | 78 | Lint the TypeScript code: 79 | 80 | ```sh 81 | $ yarn lint:ts 82 | ``` 83 | 84 | ### Coverage 85 | 86 | Generate the code coverage report: 87 | 88 | ```sh 89 | $ yarn coverage 90 | ``` 91 | 92 | ### Report Gas 93 | 94 | See the gas usage per unit test and average gas per method call: 95 | 96 | ```sh 97 | $ REPORT_GAS=true yarn test 98 | ``` 99 | 100 | ### Clean 101 | 102 | Delete the smart contract artifacts, the coverage reports and the Hardhat cache: 103 | 104 | ```sh 105 | $ yarn clean 106 | ``` 107 | 108 | ### Deploy 109 | 110 | Deploy the contracts to Hardhat Network: 111 | 112 | ```sh 113 | $ yarn deploy 114 | ``` 115 | 116 | Deploy the contracts to live network (e.g. goerli): 117 | 118 | ```sh 119 | $ yarn deploy --network goerli 120 | ``` 121 | 122 | ### Verify on Etherscan 123 | 124 | To verify on Etherscan, first get the implementation address via Etherscan: 125 | 126 | - Under the contract tab select 'is this a Proxy?' and Etherscan will display the implementation address 127 | 128 | Example for Goerli: 129 | 130 | ```sh 131 | yarn hardhat --network goerli verify CONTRACT_IMPLEMENTATION_ADDRESS 132 | ``` 133 | 134 | ## Tips 135 | 136 | ### Syntax Highlighting 137 | 138 | If you use VSCode, you can get Solidity syntax highlighting with the [hardhat-solidity](https://marketplace.visualstudio.com/items?itemName=NomicFoundation.hardhat-solidity) extension. 139 | 140 | ## Using GitPod 141 | 142 | [GitPod](https://www.gitpod.io/) is an open-source developer platform for remote development. 143 | 144 | To view the coverage report generated by `yarn coverage`, just click `Go Live` from the status bar to turn the server on/off. 145 | 146 | ## License 147 | 148 | [MIT](./LICENSE.md) © Paul Razvan Berg 149 | -------------------------------------------------------------------------------- /contracts/ERC3525SlotEnumerableUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // Ref: https://raw.githubusercontent.com/solv-finance/erc-3525/main/contracts/ERC3525SlotEnumerableUpgradeable.sol 3 | 4 | pragma solidity ^0.8.4; 5 | 6 | import "./ERC3525Upgradeable.sol"; 7 | import "./interfaces/IERC3525SlotEnumerableUpgradeable.sol"; 8 | error SlotAlreadyMinted(); 9 | error SlotOutOfBounds(uint256 slotId); 10 | error SlotTokenOutOfBounds(uint256 slotId, uint256 tokenId); 11 | 12 | contract ERC3525SlotEnumerableUpgradeable is ERC3525Upgradeable { 13 | struct SlotData { 14 | uint256 slot; 15 | uint256[] slotTokens; 16 | // mapping(uint256 => uint256) slotTokensIndex; 17 | } 18 | 19 | // slot => tokenId => index 20 | mapping(uint256 => mapping(uint256 => uint256)) private _slotTokensIndex; 21 | 22 | SlotData[] private _allSlots; 23 | 24 | // slot => index 25 | mapping(uint256 => uint256) private _allSlotsIndex; 26 | 27 | function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { 28 | return 29 | interfaceId == type(IERC3525SlotEnumerableUpgradeable).interfaceId || super.supportsInterface(interfaceId); 30 | } 31 | 32 | /** 33 | * @notice Get the total amount of slots stored by the contract. 34 | * @return The total amount of slots 35 | */ 36 | function slotCount() public view virtual returns (uint256) { 37 | return _allSlots.length; 38 | } 39 | 40 | /** 41 | * @notice Get the slot at the specified index of all slots stored by the contract. 42 | * @param index_ The index in the slot list 43 | * @return The slot at `index` of all slots. 44 | */ 45 | function slotByIndex(uint256 index_) public view virtual returns (uint256) { 46 | if (index_ >= ERC3525SlotEnumerableUpgradeable.slotCount()) { 47 | revert SlotOutOfBounds(index_); 48 | } 49 | return _allSlots[index_].slot; 50 | } 51 | 52 | function _slotExists(uint256 slot_) internal view virtual returns (bool) { 53 | return _allSlots.length != 0 && _allSlots[_allSlotsIndex[slot_]].slot == slot_; 54 | } 55 | 56 | /** 57 | * @notice Get the total amount of tokens with the same slot. 58 | * @param slot_ The slot to query token supply for 59 | * @return The total amount of tokens with the specified `_slot` 60 | */ 61 | function tokenSupplyInSlot(uint256 slot_) public view virtual returns (uint256) { 62 | if (!_slotExists(slot_)) { 63 | return 0; 64 | } 65 | return _allSlots[_allSlotsIndex[slot_]].slotTokens.length; 66 | } 67 | 68 | /** 69 | * @notice Get the token at the specified index of all tokens with the same slot. 70 | * @param slot_ The slot to query tokens with 71 | * @param index_ The index in the token list of the slot 72 | * @return The token ID at `_index` of all tokens with `_slot` 73 | */ 74 | function tokenInSlotByIndex(uint256 slot_, uint256 index_) public view virtual returns (uint256) { 75 | if (index_ >= ERC3525SlotEnumerableUpgradeable.tokenSupplyInSlot(slot_)) { 76 | revert SlotTokenOutOfBounds(slot_, index_); 77 | } 78 | return _allSlots[_allSlotsIndex[slot_]].slotTokens[index_]; 79 | } 80 | 81 | function _tokenExistsInSlot(uint256 slot_, uint256 tokenId_) private view returns (bool) { 82 | SlotData storage slotData = _allSlots[_allSlotsIndex[slot_]]; 83 | return slotData.slotTokens.length > 0 && slotData.slotTokens[_slotTokensIndex[slot_][tokenId_]] == tokenId_; 84 | } 85 | 86 | function _createSlot(uint256 slot_) internal virtual { 87 | if (_slotExists(slot_)) { 88 | revert SlotAlreadyMinted(); 89 | } 90 | SlotData memory slotData = SlotData({ slot: slot_, slotTokens: new uint256[](0) }); 91 | _addSlotToAllSlotsEnumeration(slotData); 92 | } 93 | 94 | function _beforeValueTransfer( 95 | address from_, 96 | address to_, 97 | uint256 fromTokenId_, 98 | uint256 toTokenId_, 99 | uint256 slot_, 100 | uint256 value_ 101 | ) internal virtual override { 102 | super._beforeValueTransfer(from_, to_, fromTokenId_, toTokenId_, slot_, value_); 103 | if (from_ == address(0) && fromTokenId_ == 0 && !_slotExists(slot_)) { 104 | _createSlot(slot_); 105 | } 106 | 107 | //Shh - currently unused 108 | to_; 109 | toTokenId_; 110 | value_; 111 | } 112 | 113 | function _afterValueTransfer( 114 | address from_, 115 | address to_, 116 | uint256 fromTokenId_, 117 | uint256 toTokenId_, 118 | uint256 slot_, 119 | uint256 value_ 120 | ) internal virtual override { 121 | if (from_ == address(0) && fromTokenId_ == 0 && !_tokenExistsInSlot(slot_, toTokenId_)) { 122 | _addTokenToSlotEnumeration(slot_, toTokenId_); 123 | } else if (to_ == address(0) && toTokenId_ == 0 && _tokenExistsInSlot(slot_, fromTokenId_)) { 124 | _removeTokenFromSlotEnumeration(slot_, fromTokenId_); 125 | } 126 | 127 | //Shh - currently unused 128 | value_; 129 | 130 | super._afterValueTransfer(from_, to_, fromTokenId_, toTokenId_, slot_, value_); 131 | } 132 | 133 | function _addSlotToAllSlotsEnumeration(SlotData memory slotData) private { 134 | _allSlotsIndex[slotData.slot] = _allSlots.length; 135 | _allSlots.push(slotData); 136 | } 137 | 138 | function _addTokenToSlotEnumeration(uint256 slot_, uint256 tokenId_) private { 139 | SlotData storage slotData = _allSlots[_allSlotsIndex[slot_]]; 140 | _slotTokensIndex[slot_][tokenId_] = slotData.slotTokens.length; 141 | slotData.slotTokens.push(tokenId_); 142 | } 143 | 144 | function _removeTokenFromSlotEnumeration(uint256 slot_, uint256 tokenId_) private { 145 | SlotData storage slotData = _allSlots[_allSlotsIndex[slot_]]; 146 | uint256 lastTokenIndex = slotData.slotTokens.length - 1; 147 | uint256 lastTokenId = slotData.slotTokens[lastTokenIndex]; 148 | uint256 tokenIndex = _slotTokensIndex[slot_][tokenId_]; 149 | 150 | slotData.slotTokens[tokenIndex] = lastTokenId; 151 | _slotTokensIndex[slot_][lastTokenId] = tokenIndex; 152 | 153 | delete _slotTokensIndex[slot_][tokenId_]; 154 | slotData.slotTokens.pop(); 155 | } 156 | 157 | //TODO cleanup inheritance 158 | // solhint-disable-next-line no-empty-blocks 159 | function valueDecimals() external view virtual override returns (uint8) { 160 | //empty block 161 | } 162 | 163 | // solhint-disable-next-line no-empty-blocks 164 | function tokenURI(uint256 tokenId) external view virtual override returns (string memory) { 165 | //empty block 166 | } 167 | 168 | // solhint-disable-next-line no-empty-blocks 169 | function slotURI(uint256 _slot) external view virtual override returns (string memory) { 170 | //empty block 171 | } 172 | 173 | /** 174 | * @dev This empty reserved space is put in place to allow future versions to add new 175 | * variables without shifting down storage in the inheritance chain. 176 | */ 177 | uint256[47] private __gap; 178 | } 179 | -------------------------------------------------------------------------------- /contracts/HyperCertMinter.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.14; 3 | 4 | import "./ERC3525SlotEnumerableUpgradeable.sol"; 5 | import "./interfaces/IHyperCertMetadata.sol"; 6 | import "./utils/ArraysUpgradeable.sol"; 7 | import "./utils/StringsExtensions.sol"; 8 | import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; 9 | import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; 10 | import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; 11 | 12 | error EmptyInput(); 13 | error DuplicateScope(); 14 | error InvalidScope(); 15 | error InvalidTimeframe(uint64 from, uint64 to); 16 | error ConflictingClaim(); 17 | error InvalidInput(); 18 | 19 | /// @title Hypercertificate minting logic 20 | /// @notice Contains functions and events to initialize and issue a hypercertificate 21 | /// @author bitbeckers, mr_bluesky 22 | contract HyperCertMinter is Initializable, ERC3525SlotEnumerableUpgradeable, AccessControlUpgradeable, UUPSUpgradeable { 23 | using ArraysUpgradeable for uint64[]; 24 | 25 | /// @notice Contract name 26 | string public constant NAME = "HyperCerts"; 27 | /// @notice Token symbol 28 | string public constant SYMBOL = "HCRT"; 29 | /// @notice Token value decimals 30 | uint8 public constant DECIMALS = 0; 31 | /// @notice User role required in order to upgrade the contract 32 | bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); 33 | /// @notice Current version of the contract 34 | uint16 internal _version; 35 | /// @notice Hypercert metadata contract 36 | IHyperCertMetadata internal _metadata; 37 | 38 | /// @notice Mapping of id's to work-scopes 39 | mapping(bytes32 => string) public workScopes; 40 | /// @notice Mapping of id's to impact-scopes 41 | mapping(bytes32 => string) public impactScopes; 42 | /// @notice Mapping of id's to rights 43 | mapping(bytes32 => string) public rights; 44 | mapping(address => mapping(bytes32 => bool)) internal _contributorImpacts; 45 | mapping(uint256 => Claim) internal _hyperCerts; 46 | 47 | struct Claim { 48 | bytes32 claimHash; 49 | uint64[2] workTimeframe; 50 | uint64[2] impactTimeframe; 51 | bytes32[] workScopes; 52 | bytes32[] impactScopes; 53 | bytes32[] rights; 54 | address[] contributors; 55 | uint256 totalUnits; 56 | uint16 version; 57 | bool exists; 58 | string name; 59 | string description; 60 | string uri; 61 | address minter; 62 | } 63 | 64 | /******************* 65 | * EVENTS 66 | ******************/ 67 | 68 | /// @notice Emitted when an impact is claimed. 69 | /// @param id Id of the claimed impact. 70 | /// @param minter Address of cert minter. 71 | /// @param fractions Units of tokens issued under the hypercert. 72 | event ImpactClaimed(uint256 id, address minter, uint64[] fractions); 73 | 74 | /// @notice Emitted when a new impact scope is added. 75 | /// @param id Id of the impact scope. 76 | /// @param text Short text code of the impact scope. 77 | event ImpactScopeAdded(bytes32 id, string text); 78 | 79 | /// @notice Emitted when a new right is added. 80 | /// @param id Id of the right. 81 | /// @param text Short text code of the right. 82 | event RightAdded(bytes32 id, string text); 83 | 84 | /// @notice Emitted when a new work scope is added. 85 | /// @param id Id of the work scope. 86 | /// @param text Short text code of the work scope. 87 | event WorkScopeAdded(bytes32 id, string text); 88 | 89 | /******************* 90 | * DEPLOY 91 | ******************/ 92 | 93 | /// @notice Contract constructor logic 94 | /// @custom:oz-upgrades-unsafe-allow constructor 95 | constructor() { 96 | _disableInitializers(); 97 | } 98 | 99 | /// @notice Contract initialization logic 100 | function initialize(address metadataAddress) public initializer { 101 | _metadata = IHyperCertMetadata(metadataAddress); 102 | 103 | __AccessControl_init(); 104 | __UUPSUpgradeable_init(); 105 | __ERC3525Upgradeable_init(); 106 | 107 | _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); 108 | _grantRole(UPGRADER_ROLE, msg.sender); 109 | } 110 | 111 | /******************* 112 | * PUBLIC 113 | ******************/ 114 | 115 | /// @notice Adds a new impact scope 116 | /// @param text Text representing the impact scope 117 | /// @return id Id of the impact scope 118 | function addImpactScope(string memory text) public returns (bytes32 id) { 119 | id = _authorizeAdd(text, impactScopes); 120 | impactScopes[id] = text; 121 | emit ImpactScopeAdded(id, text); 122 | } 123 | 124 | /// @notice Adds a new right 125 | /// @param text Text representing the right 126 | /// @return id Id of the right 127 | function addRight(string memory text) public returns (bytes32 id) { 128 | id = _authorizeAdd(text, rights); 129 | rights[id] = text; 130 | emit RightAdded(id, text); 131 | } 132 | 133 | /// @notice Adds a new work scope 134 | /// @param text Text representing the work scope 135 | /// @return id Id of the work scope 136 | function addWorkScope(string memory text) public returns (bytes32 id) { 137 | id = _authorizeAdd(text, workScopes); 138 | workScopes[id] = text; 139 | emit WorkScopeAdded(id, text); 140 | } 141 | 142 | /// @notice Issues a new hypercertificate 143 | /// @param account Account issuing the new hypercertificate 144 | /// @param data Data representing the parameters of the claim 145 | function mint(address account, bytes calldata data) public virtual { 146 | // Parse data to get Claim 147 | (Claim memory claim, uint64[] memory fractions) = _parseData(data); 148 | claim.minter = msg.sender; 149 | 150 | _authorizeMint(account, claim); 151 | 152 | // Check on overlapping contributor-claims and store if success 153 | _storeContributorsClaims(claim.claimHash, claim.contributors); 154 | 155 | uint256 slot = slotCount() + 1; 156 | // Store impact cert 157 | _hyperCerts[slot] = claim; 158 | 159 | // Mint impact cert 160 | uint256 len = fractions.length; 161 | for (uint256 i = 0; i < len; i++) { 162 | _mintValue(account, slot, fractions[i]); 163 | } 164 | 165 | emit ImpactClaimed(slot, account, fractions); 166 | } 167 | 168 | function split(uint256 tokenId, uint256[] calldata amounts) public { 169 | if (!_exists(tokenId)) revert NonExistentToken(tokenId); 170 | 171 | uint256 total; 172 | 173 | uint256 amountsLength = amounts.length; 174 | if (amountsLength == 1) revert AlreadyMinted(tokenId); 175 | 176 | for (uint256 i; i < amountsLength; i++) { 177 | total += amounts[i]; 178 | } 179 | 180 | if (total > balanceOf(tokenId) || total < balanceOf(tokenId)) revert InvalidInput(); 181 | 182 | for (uint256 i = 1; i < amountsLength; i++) { 183 | _splitValue(tokenId, amounts[i]); 184 | } 185 | } 186 | 187 | function merge(uint256[] memory tokenIds) public { 188 | uint256 len = tokenIds.length; 189 | uint256 targetTokenId = tokenIds[len - 1]; 190 | for (uint256 i = 0; i < len; i++) { 191 | uint256 tokenId = tokenIds[i]; 192 | if (tokenId != targetTokenId) { 193 | _mergeValue(tokenId, targetTokenId); 194 | _burn(tokenId); 195 | } 196 | } 197 | } 198 | 199 | /// @notice Gets the impact claim with the specified id 200 | /// @param claimID Id of the claim 201 | /// @return The claim, if it doesn't exist with default values 202 | function getImpactCert(uint256 claimID) public view returns (Claim memory) { 203 | return _hyperCerts[claimID]; 204 | } 205 | 206 | /// @notice gets the current version of the contract 207 | function version() public view virtual returns (uint256) { 208 | return _version; 209 | } 210 | 211 | /// @notice Update the contract version number 212 | /// @notice Only allowed for member of UPGRADER_ROLE 213 | function updateVersion() external onlyRole(UPGRADER_ROLE) { 214 | _version += 1; 215 | } 216 | 217 | /// @notice Returns a flag indicating if the contract supports the specified interface 218 | /// @param interfaceId Id of the interface 219 | /// @return true, if the interface is supported 220 | function supportsInterface(bytes4 interfaceId) 221 | public 222 | view 223 | override(ERC3525SlotEnumerableUpgradeable, AccessControlUpgradeable) 224 | returns (bool) 225 | { 226 | return super.supportsInterface(interfaceId); 227 | } 228 | 229 | function name() public pure override returns (string memory) { 230 | return NAME; 231 | } 232 | 233 | function symbol() public pure override returns (string memory) { 234 | return SYMBOL; 235 | } 236 | 237 | function valueDecimals() public view virtual override returns (uint8) { 238 | return DECIMALS; 239 | } 240 | 241 | function getHash( 242 | uint64[2] memory workTimeframe_, 243 | bytes32[] memory workScopes_, 244 | uint64[2] memory impactTimeframe_, 245 | bytes32[] memory impactScopes_ 246 | ) public pure virtual returns (bytes32) { 247 | return keccak256(abi.encode(workTimeframe_, workScopes_, impactTimeframe_, impactScopes_)); 248 | } 249 | 250 | function slotURI(uint256 slotId_) external view override returns (string memory) { 251 | if (!_hyperCerts[slotId_].exists) { 252 | revert NonExistentSlot(slotId_); 253 | } 254 | return _metadata.generateSlotURI(slotId_); 255 | } 256 | 257 | function tokenURI(uint256 tokenId_) public view override returns (string memory) { 258 | return _metadata.generateTokenURI(slotOf(tokenId_), tokenId_); 259 | } 260 | 261 | function contractURI() public view override returns (string memory) { 262 | return _metadata.generateContractURI(); 263 | } 264 | 265 | function burn(uint256 tokenId_) public { 266 | uint256 claimId = slotOf(tokenId_); 267 | Claim storage claim = _hyperCerts[claimId]; 268 | if (msg.sender != claim.minter) { 269 | revert NotApprovedOrOwner(); 270 | } 271 | 272 | if (balanceOf(tokenId_) != claim.totalUnits) { 273 | revert InsufficientBalance(claim.totalUnits, balanceOf(tokenId_)); 274 | } 275 | 276 | _clearContributorsClaims(claim.claimHash, claim.contributors); 277 | _burn(tokenId_); 278 | claim.exists = false; 279 | } 280 | 281 | function donate(uint256 tokenId_) public { 282 | if (msg.sender == ownerOf(tokenId_)) { 283 | revert NotApprovedOrOwner(); 284 | } 285 | 286 | _burn(tokenId_); 287 | } 288 | 289 | /******************* 290 | * INTERNAL 291 | ******************/ 292 | 293 | /// @notice upgrade authorization logic 294 | /// @dev adds onlyRole(UPGRADER_ROLE) requirement 295 | function _authorizeUpgrade( 296 | address /*newImplementation*/ 297 | ) 298 | internal 299 | view 300 | override 301 | onlyRole(UPGRADER_ROLE) // solhint-disable-next-line no-empty-blocks 302 | { 303 | //empty block 304 | } 305 | 306 | /// @notice Pre-add validation checks 307 | /// @param text Text to be added 308 | /// @param map Storage mapping that will be appended 309 | function _authorizeAdd(string memory text, mapping(bytes32 => string) storage map) 310 | internal 311 | view 312 | virtual 313 | returns (bytes32 id) 314 | { 315 | if (bytes(text).length == 0) { 316 | revert EmptyInput(); 317 | } 318 | id = keccak256(abi.encode(text)); 319 | if (_hasKey(map, id)) { 320 | revert DuplicateScope(); 321 | } 322 | } 323 | 324 | /// @notice Pre-mint validation checks 325 | /// @param account Destination address for the mint 326 | /// @param claim Impact claim data 327 | /* solhint-disable code-complexity */ 328 | 329 | function _authorizeMint(address account, Claim memory claim) internal view virtual { 330 | if (account == address(0)) { 331 | revert ToZeroAddress(); 332 | } 333 | if (claim.workTimeframe[0] > claim.workTimeframe[1]) { 334 | revert InvalidTimeframe(claim.workTimeframe[0], claim.workTimeframe[1]); 335 | } 336 | if (claim.impactTimeframe[0] > claim.impactTimeframe[1] && claim.impactTimeframe[0] != 0) { 337 | revert InvalidTimeframe(claim.impactTimeframe[0], claim.impactTimeframe[1]); 338 | } 339 | if (claim.workTimeframe[0] > claim.impactTimeframe[0]) { 340 | revert InvalidTimeframe(claim.workTimeframe[0], claim.impactTimeframe[0]); 341 | } 342 | 343 | uint256 impactScopelength = claim.impactScopes.length; 344 | for (uint256 i = 0; i < impactScopelength; i++) { 345 | if (bytes(impactScopes[claim.impactScopes[i]]).length == 0) { 346 | revert InvalidScope(); 347 | } 348 | } 349 | 350 | uint256 workScopelength = claim.workScopes.length; 351 | for (uint256 i = 0; i < workScopelength; i++) { 352 | if (!_hasKey(workScopes, claim.workScopes[i])) { 353 | revert InvalidScope(); 354 | } 355 | } 356 | } 357 | 358 | /* solhint-enable code-complexity */ 359 | 360 | /// @notice Parse bytes to Claim and URI 361 | /// @param data Byte data representing the claim 362 | /// @dev This function is overridable in order to support future schema changes 363 | /// @return claim The parsed Claim struct 364 | /// @return Claim metadata URI 365 | function _parseData(bytes calldata data) internal pure virtual returns (Claim memory claim, uint64[] memory) { 366 | if (data.length == 0) { 367 | revert EmptyInput(); 368 | } 369 | 370 | ( 371 | bytes32[] memory rights_, 372 | bytes32[] memory workScopes_, 373 | bytes32[] memory impactScopes_, 374 | uint64[2] memory workTimeframe, 375 | uint64[2] memory impactTimeframe, 376 | address[] memory contributors, 377 | string memory name_, 378 | string memory description_, 379 | string memory uri_, 380 | uint64[] memory fractions 381 | ) = abi.decode( 382 | data, 383 | (bytes32[], bytes32[], bytes32[], uint64[2], uint64[2], address[], string, string, string, uint64[]) 384 | ); 385 | 386 | claim.claimHash = getHash(workTimeframe, workScopes_, impactTimeframe, impactScopes_); 387 | claim.contributors = contributors; 388 | claim.workTimeframe = workTimeframe; 389 | claim.impactTimeframe = impactTimeframe; 390 | claim.workScopes = workScopes_; 391 | claim.impactScopes = impactScopes_; 392 | claim.rights = rights_; 393 | claim.totalUnits = fractions.getSum(); 394 | claim.version = uint16(0); 395 | claim.exists = true; 396 | claim.name = name_; 397 | claim.description = description_; 398 | claim.uri = uri_; 399 | 400 | return (claim, fractions); 401 | } 402 | 403 | /// @notice Stores contributor claims in the `contributorImpacts` mapping; guards against overlapping claims 404 | /// @param claimHash Claim data hash-code value 405 | /// @param creators Array of addresses for contributors 406 | function _storeContributorsClaims(bytes32 claimHash, address[] memory creators) internal { 407 | for (uint256 i = 0; i < creators.length; i++) { 408 | if (_contributorImpacts[creators[i]][claimHash]) { 409 | revert ConflictingClaim(); 410 | } 411 | _contributorImpacts[creators[i]][claimHash] = true; 412 | } 413 | } 414 | 415 | /// @notice Stores contributor claims in the `contributorImpacts` mapping; guards against overlapping claims 416 | /// @param claimHash ID of hypercert 417 | /// @param contributors Array of addresses for contributors 418 | function _clearContributorsClaims(bytes32 claimHash, address[] memory contributors) internal { 419 | uint256 len = contributors.length; 420 | for (uint256 i = 0; i < len; i++) { 421 | _contributorImpacts[contributors[i]][claimHash] = false; 422 | } 423 | } 424 | 425 | /// @notice Checks whether the supplied mapping contains the supplied key 426 | /// @param map mapping to search 427 | /// @param key key to search 428 | /// @return true, if the key exists in the mapping 429 | function _hasKey(mapping(bytes32 => string) storage map, bytes32 key) internal view returns (bool) { 430 | return (bytes(map[key]).length > 0); 431 | } 432 | 433 | function _msgSender() internal view override(ContextUpgradeable, ERC3525Upgradeable) returns (address sender) { 434 | return msg.sender; 435 | } 436 | 437 | function setMetadataGenerator(address metadataGenerator) external onlyRole(UPGRADER_ROLE) { 438 | if (metadataGenerator == address(0)) { 439 | revert ToZeroAddress(); 440 | } 441 | _metadata = IHyperCertMetadata(metadataGenerator); 442 | } 443 | } 444 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC3525MetadataUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "./IERC3525Upgradeable.sol"; 5 | import "@openzeppelin/contracts-upgradeable/interfaces/IERC721MetadataUpgradeable.sol"; 6 | 7 | /** 8 | * @title ERC-3525 Semi-Fungible Token Standard, optional extension for metadata 9 | * @dev Interfaces for any contract that wants to support query of the Uniform Resource Identifier 10 | * (URI) for the ERC3525 contract as well as a specified slot. 11 | * Because of the higher reliability of data stored in smart contracts compared to data stored in 12 | * centralized systems, it is recommended that metadata, including `contractURI`, `slotURI` and 13 | * `tokenURI`, be directly returned in JSON format, instead of being returned with a url pointing 14 | * to any resource stored in a centralized system. 15 | * See https://eips.ethereum.org/EIPS/eip-3525 16 | * Note: the ERC-165 identifier for this interface is 0xe1600902. 17 | */ 18 | interface IERC3525MetadataUpgradeable is IERC3525Upgradeable, IERC721MetadataUpgradeable { 19 | /** 20 | * @notice Returns the Uniform Resource Identifier (URI) for the current ERC3525 contract. 21 | * @dev This function SHOULD return the URI for this contract in JSON format, starting with 22 | * header `data:application/json;`. 23 | * See https://eips.ethereum.org/EIPS/eip-3525 for the JSON schema for contract URI. 24 | * @return The JSON formatted URI of the current ERC3525 contract 25 | */ 26 | function contractURI() external view returns (string memory); 27 | 28 | /** 29 | * @notice Returns the Uniform Resource Identifier (URI) for the specified slot. 30 | * @dev This function SHOULD return the URI for `_slot` in JSON format, starting with header 31 | * `data:application/json;`. 32 | * See https://eips.ethereum.org/EIPS/eip-3525 for the JSON schema for slot URI. 33 | * @return The JSON formatted URI of `_slot` 34 | */ 35 | function slotURI(uint256 _slot) external view returns (string memory); 36 | } 37 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC3525Receiver.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | /** 5 | * @title EIP-3525 token receiver interface 6 | * @dev Interface for any contract that wants to be informed by EIP-3525 contracts when receiving values from other 7 | * addresses. 8 | * Note: the EIP-165 identifier for this interface is 0x009ce20b. 9 | */ 10 | interface IERC3525Receiver { 11 | /** 12 | * @notice Handle the receipt of an EIP-3525 token value. 13 | * @dev An EIP-3525 smart contract MUST check whether this function is implemented by the recipient contract, if the 14 | * recipient contract implements this function, the EIP-3525 contract MUST call this function after a 15 | * value transfer (i.e. `transferFrom(uint256,uint256,uint256,bytes)`). 16 | * MUST return 0x009ce20b (i.e. `bytes4(keccak256('onERC3525Received(address,uint256,uint256, 17 | * uint256,bytes)'))`) if the transfer is accepted. 18 | * MUST revert or return any value other than 0x009ce20b if the transfer is rejected. 19 | * The EIP-3525 smart contract that calls this function MUST revert the transfer transaction if the return value 20 | * is not equal to 0x009ce20b. 21 | * @param _operator The address which triggered the transfer 22 | * @param _fromTokenId The token id to transfer value from 23 | * @param _toTokenId The token id to transfer value to 24 | * @param _value The transferred value 25 | * @param _data Additional data with no specified format 26 | * @return `bytes4(keccak256('onERC3525Received(address,uint256,uint256,uint256,bytes)'))` 27 | * unless the transfer is rejected. 28 | */ 29 | function onERC3525Received( 30 | address _operator, 31 | uint256 _fromTokenId, 32 | uint256 _toTokenId, 33 | uint256 _value, 34 | bytes calldata _data 35 | ) external returns (bytes4); 36 | } 37 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC3525SlotApprovableUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "./IERC3525Upgradeable.sol"; 5 | 6 | /** 7 | * @title EIP-3525 Semi-Fungible Token Standard, optional extension for approval of slot level 8 | * @dev Interfaces for any contract that wants to support approval of slot level, which allows an 9 | * operator to manage one's tokens with the same slot. 10 | * See https://eips.ethereum.org/EIPS/eip-3525 11 | * Note: the EIP-165 identifier for this interface is 0xb688be58. 12 | */ 13 | interface IERC3525SlotApprovableUpgradeable is IERC3525Upgradeable { 14 | /** 15 | * @dev MUST emit when an operator is approved or disapproved to manage all of `_owner`'s 16 | * tokens with the same slot. 17 | * @param _owner The address whose tokens are approved 18 | * @param _slot The slot to approve, all of `_owner`'s tokens with this slot are approved 19 | * @param _operator The operator being approved or disapproved 20 | * @param _approved Identify if `_operator` is approved or disapproved 21 | */ 22 | event ApprovalForSlot(address indexed _owner, uint256 indexed _slot, address indexed _operator, bool _approved); 23 | 24 | /** 25 | * @notice Approve or disapprove an operator to manage all of `_owner`'s tokens with the 26 | * specified slot. 27 | * @dev Caller SHOULD be `_owner` or an operator who has been authorized through 28 | * `setApprovalForAll`. 29 | * MUST emit ApprovalSlot event. 30 | * @param _owner The address that owns the EIP-3525 tokens 31 | * @param _slot The slot of tokens being queried approval of 32 | * @param _operator The address for whom to query approval 33 | * @param _approved Identify if `_operator` would be approved or disapproved 34 | */ 35 | function setApprovalForSlot( 36 | address _owner, 37 | uint256 _slot, 38 | address _operator, 39 | bool _approved 40 | ) external payable; 41 | 42 | /** 43 | * @notice Query if `_operator` is authorized to manage all of `_owner`'s tokens with the 44 | * specified slot. 45 | * @param _owner The address that owns the EIP-3525 tokens 46 | * @param _slot The slot of tokens being queried approval of 47 | * @param _operator The address for whom to query approval 48 | * @return True if `_operator` is authorized to manage all of `_owner`'s tokens with `_slot`, 49 | * false otherwise. 50 | */ 51 | function isApprovedForSlot( 52 | address _owner, 53 | uint256 _slot, 54 | address _operator 55 | ) external view returns (bool); 56 | } 57 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC3525SlotEnumerableUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "./IERC3525Upgradeable.sol"; 5 | import "@openzeppelin/contracts-upgradeable/interfaces/IERC721EnumerableUpgradeable.sol"; 6 | 7 | /** 8 | * @title EIP-3525 Semi-Fungible Token Standard, optional extension for slot enumeration 9 | * @dev Interfaces for any contract that wants to support enumeration of slots as well as tokens 10 | * with the same slot. 11 | * Note: the EIP-165 identifier for this interface is 0x3b741b9e. 12 | */ 13 | interface IERC3525SlotEnumerableUpgradeable is IERC3525Upgradeable, IERC721EnumerableUpgradeable { 14 | /** 15 | * @notice Get the total amount of slots stored by the contract. 16 | * @return The total amount of slots 17 | */ 18 | function slotCount() external view returns (uint256); 19 | 20 | /** 21 | * @notice Get the slot at the specified index of all slots stored by the contract. 22 | * @param _index The index in the slot list 23 | * @return The slot at `index` of all slots. 24 | */ 25 | function slotByIndex(uint256 _index) external view returns (uint256); 26 | 27 | /** 28 | * @notice Get the total amount of tokens with the same slot. 29 | * @param _slot The slot to query token supply for 30 | * @return The total amount of tokens with the specified `_slot` 31 | */ 32 | function tokenSupplyInSlot(uint256 _slot) external view returns (uint256); 33 | 34 | /** 35 | * @notice Get the token at the specified index of all tokens with the same slot. 36 | * @param _slot The slot to query tokens with 37 | * @param _index The index in the token list of the slot 38 | * @return The token ID at `_index` of all tokens with `_slot` 39 | */ 40 | function tokenInSlotByIndex(uint256 _slot, uint256 _index) external view returns (uint256); 41 | } 42 | -------------------------------------------------------------------------------- /contracts/interfaces/IERC3525Upgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "@openzeppelin/contracts-upgradeable/token/ERC721/IERC721Upgradeable.sol"; 5 | 6 | /** 7 | * @title ERC-3525 Semi-Fungible Token Standard 8 | * Note: the EIP-165 identifier for this interface is 0xd5358140. 9 | * @dev See https://eips.ethereum.org/EIPS/eip-3525 10 | */ 11 | interface IERC3525Upgradeable is IERC721Upgradeable { 12 | /** 13 | * @dev MUST emit when value of a token is transferred to another token with the same slot, 14 | * including zero value transfers (_value == 0) as well as transfers when tokens are created 15 | * (`_fromTokenId` == 0) or destroyed (`_toTokenId` == 0). 16 | * @param _fromTokenId The token id to transfer value from 17 | * @param _toTokenId The token id to transfer value to 18 | * @param _value The transferred value 19 | */ 20 | event TransferValue(uint256 indexed _fromTokenId, uint256 indexed _toTokenId, uint256 _value); 21 | 22 | /** 23 | * @dev MUST emit when the approval value of a token is set or changed. 24 | * @param _tokenId The token to approve 25 | * @param _operator The operator to approve for 26 | * @param _value The maximum value that `_operator` is allowed to manage 27 | */ 28 | event ApprovalValue(uint256 indexed _tokenId, address indexed _operator, uint256 _value); 29 | 30 | /** 31 | * @dev MUST emit when the slot of a token is set or changed. 32 | * @param _tokenId The token of which slot is set or changed 33 | * @param _oldSlot The previous slot of the token 34 | * @param _newSlot The updated slot of the token 35 | */ 36 | event SlotChanged(uint256 indexed _tokenId, uint256 indexed _oldSlot, uint256 indexed _newSlot); 37 | 38 | /** 39 | * @notice Get the number of decimals the token uses for value - e.g. 6, means the user 40 | * representation of the value of a token can be calculated by dividing it by 1,000,000. 41 | * Considering the compatibility with third-party wallets, this function is defined as 42 | * `valueDecimals()` instead of `decimals()` to avoid conflict with EIP-20 tokens. 43 | * @return The number of decimals for value 44 | */ 45 | function valueDecimals() external view returns (uint8); 46 | 47 | /** 48 | * @notice Get the value of a token. 49 | * @param _tokenId The token for which to query the balance 50 | * @return The value of `_tokenId` 51 | */ 52 | function balanceOf(uint256 _tokenId) external view returns (uint256); 53 | 54 | /** 55 | * @notice Get the slot of a token. 56 | * @param _tokenId The identifier for a token 57 | * @return The slot of the token 58 | */ 59 | function slotOf(uint256 _tokenId) external view returns (uint256); 60 | 61 | /** 62 | * @notice Allow an operator to manage the value of a token, up to the `_value`. 63 | * @dev MUST revert unless caller is the current owner, an authorized operator, or the approved 64 | * address for `_tokenId`. 65 | * MUST emit the ApprovalValue event. 66 | * @param _tokenId The token to approve 67 | * @param _operator The operator to be approved 68 | * @param _value The maximum value of `_toTokenId` that `_operator` is allowed to manage 69 | */ 70 | function approve( 71 | uint256 _tokenId, 72 | address _operator, 73 | uint256 _value 74 | ) external payable; 75 | 76 | /** 77 | * @notice Get the maximum value of a token that an operator is allowed to manage. 78 | * @param _tokenId The token for which to query the allowance 79 | * @param _operator The address of an operator 80 | * @return The current approval value of `_tokenId` that `_operator` is allowed to manage 81 | */ 82 | function allowance(uint256 _tokenId, address _operator) external view returns (uint256); 83 | 84 | /** 85 | * @notice Transfer value from a specified token to another specified token with the same slot. 86 | * @dev Caller MUST be the current owner, an authorized operator or an operator who has been 87 | * approved the whole `_fromTokenId` or part of it. 88 | * MUST revert if `_fromTokenId` or `_toTokenId` is zero token id or does not exist. 89 | * MUST revert if slots of `_fromTokenId` and `_toTokenId` do not match. 90 | * MUST revert if `_value` exceeds the balance of `_fromTokenId` or its allowance to the 91 | * operator. 92 | * MUST emit `TransferValue` event. 93 | * @param _fromTokenId The token to transfer value from 94 | * @param _toTokenId The token to transfer value to 95 | * @param _value The transferred value 96 | */ 97 | function transferFrom( 98 | uint256 _fromTokenId, 99 | uint256 _toTokenId, 100 | uint256 _value 101 | ) external payable; 102 | 103 | /** 104 | * @notice Transfer value from a specified token to an address. The caller should confirm that 105 | * `_to` is capable of receiving EIP-3525 tokens. 106 | * @dev This function MUST create a new EIP-3525 token with the same slot for `_to`, 107 | * or find an existing token with the same slot owned by `_to`, to receive the transferred value. 108 | * MUST revert if `_fromTokenId` is zero token id or does not exist. 109 | * MUST revert if `_to` is zero address. 110 | * MUST revert if `_value` exceeds the balance of `_fromTokenId` or its allowance to the 111 | * operator. 112 | * MUST emit `Transfer` and `TransferValue` events. 113 | * @param _fromTokenId The token to transfer value from 114 | * @param _to The address to transfer value to 115 | * @param _value The transferred value 116 | * @return ID of the token which receives the transferred value 117 | */ 118 | function transferFrom( 119 | uint256 _fromTokenId, 120 | address _to, 121 | uint256 _value 122 | ) external payable returns (uint256); 123 | } 124 | -------------------------------------------------------------------------------- /contracts/interfaces/IHyperCertMetadata.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | /** 5 | * @title Hypercert metadata generator interface 6 | */ 7 | interface IHyperCertMetadata { 8 | function generateContractURI() external view returns (string memory); 9 | 10 | function generateSlotURI(uint256 slotId) external view returns (string memory); 11 | 12 | function generateTokenURI(uint256 slotId, uint256 tokenId) external view returns (string memory); 13 | } 14 | -------------------------------------------------------------------------------- /contracts/lib/DateTime.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.4; 3 | 4 | // ---------------------------------------------------------------------------- 5 | // DateTime Library v2.0 6 | // 7 | // A gas-efficient Solidity date and time library 8 | // 9 | // https://github.com/bokkypoobah/BokkyPooBahsDateTimeLibrary 10 | // 11 | // Tested date range 1970/01/01 to 2345/12/31 12 | // 13 | // Conventions: 14 | // Unit | Range | Notes 15 | // :-------- |:-------------:|:----- 16 | // timestamp | >= 0 | Unix timestamp, number of seconds since 1970/01/01 00:00:00 UTC 17 | // year | 1970 ... 2345 | 18 | // month | 1 ... 12 | 19 | // day | 1 ... 31 | 20 | // hour | 0 ... 23 | 21 | // minute | 0 ... 59 | 22 | // second | 0 ... 59 | 23 | // dayOfWeek | 1 ... 7 | 1 = Monday, ..., 7 = Sunday 24 | // 25 | // 26 | // Enjoy. (c) BokkyPooBah / Bok Consulting Pty Ltd 2018-2019. The MIT Licence. 27 | // ---------------------------------------------------------------------------- 28 | 29 | library DateTime { 30 | uint256 constant SECONDS_PER_DAY = 24 * 60 * 60; 31 | uint256 constant SECONDS_PER_HOUR = 60 * 60; 32 | uint256 constant SECONDS_PER_MINUTE = 60; 33 | int256 constant OFFSET19700101 = 2440588; 34 | 35 | uint256 constant DOW_MON = 1; 36 | uint256 constant DOW_TUE = 2; 37 | uint256 constant DOW_WED = 3; 38 | uint256 constant DOW_THU = 4; 39 | uint256 constant DOW_FRI = 5; 40 | uint256 constant DOW_SAT = 6; 41 | uint256 constant DOW_SUN = 7; 42 | 43 | // ------------------------------------------------------------------------ 44 | // Calculate the number of days from 1970/01/01 to year/month/day using 45 | // the date conversion algorithm from 46 | // http://aa.usno.navy.mil/faq/docs/JD_Formula.php 47 | // and subtracting the offset 2440588 so that 1970/01/01 is day 0 48 | // 49 | // days = day 50 | // - 32075 51 | // + 1461 * (year + 4800 + (month - 14) / 12) / 4 52 | // + 367 * (month - 2 - (month - 14) / 12 * 12) / 12 53 | // - 3 * ((year + 4900 + (month - 14) / 12) / 100) / 4 54 | // - offset 55 | // ------------------------------------------------------------------------ 56 | function _daysFromDate( 57 | uint256 year, 58 | uint256 month, 59 | uint256 day 60 | ) internal pure returns (uint256 _days) { 61 | require(year >= 1970); 62 | int256 _year = int256(year); 63 | int256 _month = int256(month); 64 | int256 _day = int256(day); 65 | 66 | int256 __days = _day - 67 | 32075 + 68 | (1461 * (_year + 4800 + (_month - 14) / 12)) / 69 | 4 + 70 | (367 * (_month - 2 - ((_month - 14) / 12) * 12)) / 71 | 12 - 72 | (3 * ((_year + 4900 + (_month - 14) / 12) / 100)) / 73 | 4 - 74 | OFFSET19700101; 75 | 76 | _days = uint256(__days); 77 | } 78 | 79 | // ------------------------------------------------------------------------ 80 | // Calculate year/month/day from the number of days since 1970/01/01 using 81 | // the date conversion algorithm from 82 | // http://aa.usno.navy.mil/faq/docs/JD_Formula.php 83 | // and adding the offset 2440588 so that 1970/01/01 is day 0 84 | // 85 | // int L = days + 68569 + offset 86 | // int N = 4 * L / 146097 87 | // L = L - (146097 * N + 3) / 4 88 | // year = 4000 * (L + 1) / 1461001 89 | // L = L - 1461 * year / 4 + 31 90 | // month = 80 * L / 2447 91 | // dd = L - 2447 * month / 80 92 | // L = month / 11 93 | // month = month + 2 - 12 * L 94 | // year = 100 * (N - 49) + year + L 95 | // ------------------------------------------------------------------------ 96 | function _daysToDate(uint256 _days) 97 | internal 98 | pure 99 | returns ( 100 | uint256 year, 101 | uint256 month, 102 | uint256 day 103 | ) 104 | { 105 | unchecked { 106 | int256 __days = int256(_days); 107 | 108 | int256 L = __days + 68569 + OFFSET19700101; 109 | int256 N = (4 * L) / 146097; 110 | L = L - (146097 * N + 3) / 4; 111 | int256 _year = (4000 * (L + 1)) / 1461001; 112 | L = L - (1461 * _year) / 4 + 31; 113 | int256 _month = (80 * L) / 2447; 114 | int256 _day = L - (2447 * _month) / 80; 115 | L = _month / 11; 116 | _month = _month + 2 - 12 * L; 117 | _year = 100 * (N - 49) + _year + L; 118 | 119 | year = uint256(_year); 120 | month = uint256(_month); 121 | day = uint256(_day); 122 | } 123 | } 124 | 125 | function timestampFromDate( 126 | uint256 year, 127 | uint256 month, 128 | uint256 day 129 | ) internal pure returns (uint256 timestamp) { 130 | timestamp = _daysFromDate(year, month, day) * SECONDS_PER_DAY; 131 | } 132 | 133 | function timestampFromDateTime( 134 | uint256 year, 135 | uint256 month, 136 | uint256 day, 137 | uint256 hour, 138 | uint256 minute, 139 | uint256 second 140 | ) internal pure returns (uint256 timestamp) { 141 | timestamp = 142 | _daysFromDate(year, month, day) * 143 | SECONDS_PER_DAY + 144 | hour * 145 | SECONDS_PER_HOUR + 146 | minute * 147 | SECONDS_PER_MINUTE + 148 | second; 149 | } 150 | 151 | function timestampToDate(uint256 timestamp) 152 | internal 153 | pure 154 | returns ( 155 | uint256 year, 156 | uint256 month, 157 | uint256 day 158 | ) 159 | { 160 | unchecked { 161 | (year, month, day) = _daysToDate(timestamp / SECONDS_PER_DAY); 162 | } 163 | } 164 | 165 | function timestampToDateTime(uint256 timestamp) 166 | internal 167 | pure 168 | returns ( 169 | uint256 year, 170 | uint256 month, 171 | uint256 day, 172 | uint256 hour, 173 | uint256 minute, 174 | uint256 second 175 | ) 176 | { 177 | unchecked { 178 | (year, month, day) = _daysToDate(timestamp / SECONDS_PER_DAY); 179 | uint256 secs = timestamp % SECONDS_PER_DAY; 180 | hour = secs / SECONDS_PER_HOUR; 181 | secs = secs % SECONDS_PER_HOUR; 182 | minute = secs / SECONDS_PER_MINUTE; 183 | second = secs % SECONDS_PER_MINUTE; 184 | } 185 | } 186 | 187 | function isValidDate( 188 | uint256 year, 189 | uint256 month, 190 | uint256 day 191 | ) internal pure returns (bool valid) { 192 | if (year >= 1970 && month > 0 && month <= 12) { 193 | uint256 daysInMonth = _getDaysInMonth(year, month); 194 | if (day > 0 && day <= daysInMonth) { 195 | valid = true; 196 | } 197 | } 198 | } 199 | 200 | function isValidDateTime( 201 | uint256 year, 202 | uint256 month, 203 | uint256 day, 204 | uint256 hour, 205 | uint256 minute, 206 | uint256 second 207 | ) internal pure returns (bool valid) { 208 | if (isValidDate(year, month, day)) { 209 | if (hour < 24 && minute < 60 && second < 60) { 210 | valid = true; 211 | } 212 | } 213 | } 214 | 215 | function isLeapYear(uint256 timestamp) internal pure returns (bool leapYear) { 216 | (uint256 year, , ) = _daysToDate(timestamp / SECONDS_PER_DAY); 217 | leapYear = _isLeapYear(year); 218 | } 219 | 220 | function _isLeapYear(uint256 year) internal pure returns (bool leapYear) { 221 | leapYear = ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0); 222 | } 223 | 224 | function isWeekDay(uint256 timestamp) internal pure returns (bool weekDay) { 225 | weekDay = getDayOfWeek(timestamp) <= DOW_FRI; 226 | } 227 | 228 | function isWeekEnd(uint256 timestamp) internal pure returns (bool weekEnd) { 229 | weekEnd = getDayOfWeek(timestamp) >= DOW_SAT; 230 | } 231 | 232 | function getDaysInMonth(uint256 timestamp) internal pure returns (uint256 daysInMonth) { 233 | (uint256 year, uint256 month, ) = _daysToDate(timestamp / SECONDS_PER_DAY); 234 | daysInMonth = _getDaysInMonth(year, month); 235 | } 236 | 237 | function _getDaysInMonth(uint256 year, uint256 month) internal pure returns (uint256 daysInMonth) { 238 | if (month == 1 || month == 3 || month == 5 || month == 7 || month == 8 || month == 10 || month == 12) { 239 | daysInMonth = 31; 240 | } else if (month != 2) { 241 | daysInMonth = 30; 242 | } else { 243 | daysInMonth = _isLeapYear(year) ? 29 : 28; 244 | } 245 | } 246 | 247 | // 1 = Monday, 7 = Sunday 248 | function getDayOfWeek(uint256 timestamp) internal pure returns (uint256 dayOfWeek) { 249 | uint256 _days = timestamp / SECONDS_PER_DAY; 250 | dayOfWeek = ((_days + 3) % 7) + 1; 251 | } 252 | 253 | function getYear(uint256 timestamp) internal pure returns (uint256 year) { 254 | (year, , ) = _daysToDate(timestamp / SECONDS_PER_DAY); 255 | } 256 | 257 | function getMonth(uint256 timestamp) internal pure returns (uint256 month) { 258 | (, month, ) = _daysToDate(timestamp / SECONDS_PER_DAY); 259 | } 260 | 261 | function getDay(uint256 timestamp) internal pure returns (uint256 day) { 262 | (, , day) = _daysToDate(timestamp / SECONDS_PER_DAY); 263 | } 264 | 265 | function getHour(uint256 timestamp) internal pure returns (uint256 hour) { 266 | uint256 secs = timestamp % SECONDS_PER_DAY; 267 | hour = secs / SECONDS_PER_HOUR; 268 | } 269 | 270 | function getMinute(uint256 timestamp) internal pure returns (uint256 minute) { 271 | uint256 secs = timestamp % SECONDS_PER_HOUR; 272 | minute = secs / SECONDS_PER_MINUTE; 273 | } 274 | 275 | function getSecond(uint256 timestamp) internal pure returns (uint256 second) { 276 | second = timestamp % SECONDS_PER_MINUTE; 277 | } 278 | 279 | function addYears(uint256 timestamp, uint256 _years) internal pure returns (uint256 newTimestamp) { 280 | (uint256 year, uint256 month, uint256 day) = _daysToDate(timestamp / SECONDS_PER_DAY); 281 | year += _years; 282 | uint256 daysInMonth = _getDaysInMonth(year, month); 283 | if (day > daysInMonth) { 284 | day = daysInMonth; 285 | } 286 | newTimestamp = _daysFromDate(year, month, day) * SECONDS_PER_DAY + (timestamp % SECONDS_PER_DAY); 287 | require(newTimestamp >= timestamp); 288 | } 289 | 290 | function addMonths(uint256 timestamp, uint256 _months) internal pure returns (uint256 newTimestamp) { 291 | (uint256 year, uint256 month, uint256 day) = _daysToDate(timestamp / SECONDS_PER_DAY); 292 | month += _months; 293 | year += (month - 1) / 12; 294 | month = ((month - 1) % 12) + 1; 295 | uint256 daysInMonth = _getDaysInMonth(year, month); 296 | if (day > daysInMonth) { 297 | day = daysInMonth; 298 | } 299 | newTimestamp = _daysFromDate(year, month, day) * SECONDS_PER_DAY + (timestamp % SECONDS_PER_DAY); 300 | require(newTimestamp >= timestamp); 301 | } 302 | 303 | function addDays(uint256 timestamp, uint256 _days) internal pure returns (uint256 newTimestamp) { 304 | newTimestamp = timestamp + _days * SECONDS_PER_DAY; 305 | require(newTimestamp >= timestamp); 306 | } 307 | 308 | function addHours(uint256 timestamp, uint256 _hours) internal pure returns (uint256 newTimestamp) { 309 | newTimestamp = timestamp + _hours * SECONDS_PER_HOUR; 310 | require(newTimestamp >= timestamp); 311 | } 312 | 313 | function addMinutes(uint256 timestamp, uint256 _minutes) internal pure returns (uint256 newTimestamp) { 314 | newTimestamp = timestamp + _minutes * SECONDS_PER_MINUTE; 315 | require(newTimestamp >= timestamp); 316 | } 317 | 318 | function addSeconds(uint256 timestamp, uint256 _seconds) internal pure returns (uint256 newTimestamp) { 319 | newTimestamp = timestamp + _seconds; 320 | require(newTimestamp >= timestamp); 321 | } 322 | 323 | function subYears(uint256 timestamp, uint256 _years) internal pure returns (uint256 newTimestamp) { 324 | (uint256 year, uint256 month, uint256 day) = _daysToDate(timestamp / SECONDS_PER_DAY); 325 | year -= _years; 326 | uint256 daysInMonth = _getDaysInMonth(year, month); 327 | if (day > daysInMonth) { 328 | day = daysInMonth; 329 | } 330 | newTimestamp = _daysFromDate(year, month, day) * SECONDS_PER_DAY + (timestamp % SECONDS_PER_DAY); 331 | require(newTimestamp <= timestamp); 332 | } 333 | 334 | function subMonths(uint256 timestamp, uint256 _months) internal pure returns (uint256 newTimestamp) { 335 | (uint256 year, uint256 month, uint256 day) = _daysToDate(timestamp / SECONDS_PER_DAY); 336 | uint256 yearMonth = year * 12 + (month - 1) - _months; 337 | year = yearMonth / 12; 338 | month = (yearMonth % 12) + 1; 339 | uint256 daysInMonth = _getDaysInMonth(year, month); 340 | if (day > daysInMonth) { 341 | day = daysInMonth; 342 | } 343 | newTimestamp = _daysFromDate(year, month, day) * SECONDS_PER_DAY + (timestamp % SECONDS_PER_DAY); 344 | require(newTimestamp <= timestamp); 345 | } 346 | 347 | function subDays(uint256 timestamp, uint256 _days) internal pure returns (uint256 newTimestamp) { 348 | newTimestamp = timestamp - _days * SECONDS_PER_DAY; 349 | require(newTimestamp <= timestamp); 350 | } 351 | 352 | function subHours(uint256 timestamp, uint256 _hours) internal pure returns (uint256 newTimestamp) { 353 | newTimestamp = timestamp - _hours * SECONDS_PER_HOUR; 354 | require(newTimestamp <= timestamp); 355 | } 356 | 357 | function subMinutes(uint256 timestamp, uint256 _minutes) internal pure returns (uint256 newTimestamp) { 358 | newTimestamp = timestamp - _minutes * SECONDS_PER_MINUTE; 359 | require(newTimestamp <= timestamp); 360 | } 361 | 362 | function subSeconds(uint256 timestamp, uint256 _seconds) internal pure returns (uint256 newTimestamp) { 363 | newTimestamp = timestamp - _seconds; 364 | require(newTimestamp <= timestamp); 365 | } 366 | 367 | function diffYears(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _years) { 368 | require(fromTimestamp <= toTimestamp); 369 | (uint256 fromYear, , ) = _daysToDate(fromTimestamp / SECONDS_PER_DAY); 370 | (uint256 toYear, , ) = _daysToDate(toTimestamp / SECONDS_PER_DAY); 371 | _years = toYear - fromYear; 372 | } 373 | 374 | function diffMonths(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _months) { 375 | require(fromTimestamp <= toTimestamp); 376 | (uint256 fromYear, uint256 fromMonth, ) = _daysToDate(fromTimestamp / SECONDS_PER_DAY); 377 | (uint256 toYear, uint256 toMonth, ) = _daysToDate(toTimestamp / SECONDS_PER_DAY); 378 | _months = toYear * 12 + toMonth - fromYear * 12 - fromMonth; 379 | } 380 | 381 | function diffDays(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _days) { 382 | require(fromTimestamp <= toTimestamp); 383 | _days = (toTimestamp - fromTimestamp) / SECONDS_PER_DAY; 384 | } 385 | 386 | function diffHours(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _hours) { 387 | require(fromTimestamp <= toTimestamp); 388 | _hours = (toTimestamp - fromTimestamp) / SECONDS_PER_HOUR; 389 | } 390 | 391 | function diffMinutes(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _minutes) { 392 | require(fromTimestamp <= toTimestamp); 393 | _minutes = (toTimestamp - fromTimestamp) / SECONDS_PER_MINUTE; 394 | } 395 | 396 | function diffSeconds(uint256 fromTimestamp, uint256 toTimestamp) internal pure returns (uint256 _seconds) { 397 | require(fromTimestamp <= toTimestamp); 398 | _seconds = toTimestamp - fromTimestamp; 399 | } 400 | } 401 | -------------------------------------------------------------------------------- /contracts/mocks/ERC3525_Testing.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "../ERC3525SlotEnumerableUpgradeable.sol"; 5 | 6 | /** 7 | * @dev Mock implementation of ERC3525 to expose private mutative functions 8 | */ 9 | // solhint-disable-next-line contract-name-camelcase 10 | contract ERC3525_Testing is ERC3525SlotEnumerableUpgradeable { 11 | // solhint-disable-next-line no-empty-blocks 12 | function initialize() public initializer { 13 | // empty block 14 | } 15 | 16 | function mintValue( 17 | address to_, 18 | uint256 slot_, 19 | uint256 value_ 20 | ) public { 21 | _mintValue(to_, slot_, value_); 22 | } 23 | 24 | function burn(uint256 tokenId_) public { 25 | _burn(tokenId_); 26 | } 27 | 28 | function transferValue( 29 | uint256 fromTokenId_, 30 | uint256 toTokenId_, 31 | uint256 value_ 32 | ) public { 33 | _transferValue(fromTokenId_, toTokenId_, value_); 34 | } 35 | 36 | function spendAllowance( 37 | address operator_, 38 | uint256 tokenId_, 39 | uint256 value_ 40 | ) public { 41 | _spendAllowance(operator_, tokenId_, value_); 42 | } 43 | 44 | function approveValue( 45 | uint256 tokenId_, 46 | address to_, 47 | uint256 value_ 48 | ) public { 49 | _approveValue(tokenId_, to_, value_); 50 | } 51 | 52 | function isApprovedOrOwner(address operator_, uint256 tokenId_) public view virtual returns (bool) { 53 | return _isApprovedOrOwner(operator_, tokenId_); 54 | } 55 | 56 | function slotURI( 57 | uint256 /*slot_*/ 58 | ) public pure override returns (string memory) { 59 | return 60 | string( 61 | abi.encodePacked( 62 | "data:application/json;{" 63 | "name" 64 | ":" 65 | "Slot Type A" 66 | "," 67 | "description" 68 | ":" 69 | "Slot Type A description" 70 | "}" 71 | ) 72 | ); 73 | } 74 | 75 | function tokenURI(uint256 tokenID_) public view override returns (string memory) { 76 | return 77 | string( 78 | abi.encodePacked( 79 | "data:application/json;{" 80 | "name" 81 | ":" 82 | "Asset Type A" 83 | "," 84 | "description" 85 | ":" 86 | "Asset Type A description" 87 | "," 88 | "balance", 89 | balanceOf(tokenID_), 90 | "," 91 | "slot" 92 | ":", 93 | slotOf(tokenID_), 94 | "}" 95 | ) 96 | ); 97 | } 98 | 99 | function valueDecimals() public pure override returns (uint8) { 100 | return 0; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /contracts/mocks/HyperCertMinterUpgrade.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | pragma solidity ^0.8.4; 3 | 4 | import "../HyperCertMinter.sol"; 5 | 6 | contract HyperCertMinterUpgrade is HyperCertMinter { 7 | /// @notice Contract constructor logic 8 | /// @custom:oz-upgrades-unsafe-allow constructor 9 | constructor() { 10 | _disableInitializers(); 11 | } 12 | 13 | function mockedUpgradeFunction() public pure returns (bool) { 14 | return true; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /contracts/utils/ArraysUpgradeable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.4; 4 | 5 | import "@openzeppelin/contracts-upgradeable/utils/StringsUpgradeable.sol"; 6 | 7 | /** 8 | * @dev Collection of functions related to array types. 9 | */ 10 | library ArraysUpgradeable { 11 | using StringsUpgradeable for uint256; 12 | 13 | /** 14 | * @dev calculate the sum of the elements of an array 15 | */ 16 | function getSum(uint64[] memory array) internal pure returns (uint64) { 17 | if (array.length == 0) { 18 | return 0; 19 | } 20 | 21 | uint64 sum = 0; 22 | for (uint256 i = 0; i < array.length; i++) sum += array[i]; 23 | return sum; 24 | } 25 | 26 | function toString(uint64[2] memory array) internal pure returns (string memory) { 27 | return string(abi.encodePacked('["', uint256(array[0]).toString(), '","', uint256(array[1]).toString(), '"]')); 28 | } 29 | 30 | function toCsv(string[] memory array) internal pure returns (string memory) { 31 | uint256 len = array.length; 32 | string memory result; 33 | for (uint256 i = 0; i < len; i++) { 34 | string memory s = string(abi.encodePacked('"', array[i], '"')); 35 | if (bytes(result).length == 0) result = s; 36 | else result = string(abi.encodePacked(result, ",", s)); 37 | } 38 | 39 | return result; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /contracts/utils/StringsExtensions.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: UNLICENSED 2 | 3 | pragma solidity ^0.8.4; 4 | 5 | /** 6 | * @dev Collection of functions related to array types. 7 | */ 8 | library StringsExtensions { 9 | /** 10 | * @dev returns either "true" or "false" 11 | */ 12 | function toString(bool value) internal pure returns (string memory) { 13 | if (value) return "true"; 14 | return "false"; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /deploy/000_HyperCertSVG_Init .ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | try { 10 | const exists = await get("HyperCertSVG"); 11 | if (exists && hre.network.name !== "hardhat") { 12 | console.log("Already deployed HyperCertSVG"); 13 | } 14 | } catch { 15 | const HyperCertSVG = await ethers.getContractFactory("HyperCertSVG"); 16 | const proxy = await upgrades.deployProxy(HyperCertSVG, [], { 17 | kind: "uups", 18 | }); 19 | console.log("Deployed HyperCertSVG + proxy: " + proxy.address); 20 | 21 | const artifact = await deployments.getExtendedArtifact("HyperCertSVG"); 22 | const proxyDeployments = { 23 | address: proxy.address, 24 | ...artifact, 25 | }; 26 | 27 | await save("HyperCertSVG", proxyDeployments); 28 | } 29 | }; 30 | 31 | export default deploy; 32 | deploy.tags = ["minter", "local", "staging", "svg"]; 33 | -------------------------------------------------------------------------------- /deploy/001_HyperCertMetadata_Init.ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | try { 10 | const exists = await get("HyperCertMetadata"); 11 | if (exists && hre.network.name !== "hardhat") { 12 | console.log("Already deployed HyperCertMetadata"); 13 | } 14 | } catch { 15 | const HyperCertSVG = await get("HyperCertSVG"); 16 | 17 | const HyperCertMetadata = await ethers.getContractFactory("HyperCertMetadata"); 18 | const proxy = await upgrades.deployProxy(HyperCertMetadata, [HyperCertSVG.address], { 19 | kind: "uups", 20 | }); 21 | console.log("Deployed HyperCertMetadata + Proxy: " + proxy.address); 22 | 23 | const artifact = await deployments.getExtendedArtifact("HyperCertMetadata"); 24 | const proxyDeployments = { 25 | address: proxy.address, 26 | ...artifact, 27 | }; 28 | 29 | await save("HyperCertMetadata", proxyDeployments); 30 | } 31 | }; 32 | 33 | export default deploy; 34 | deploy.tags = ["minter", "local", "staging"]; 35 | deploy.dependencies = ["HyperCertSVG"]; 36 | -------------------------------------------------------------------------------- /deploy/002_HyperCertMinter_Init.ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | try { 10 | const exists = await get("HyperCertMinter"); 11 | if (exists && hre.network.name !== "hardhat") { 12 | console.log("Already deployed HyperCertMinter"); 13 | } 14 | } catch { 15 | const HypercertMetadata = await get("HyperCertMetadata"); 16 | 17 | const HypercertMinter = await ethers.getContractFactory("HyperCertMinter"); 18 | const proxy = await upgrades.deployProxy(HypercertMinter, [HypercertMetadata.address], { 19 | kind: "uups", 20 | }); 21 | console.log("Deployed HyperCertMinter + Proxy: " + proxy.address); 22 | 23 | const artifact = await deployments.getExtendedArtifact("HyperCertMinter"); 24 | const proxyDeployments = { 25 | address: proxy.address, 26 | ...artifact, 27 | }; 28 | 29 | await save("HyperCertMinter", proxyDeployments); 30 | } 31 | }; 32 | 33 | export default deploy; 34 | deploy.tags = ["local", "staging"]; 35 | deploy.dependencies = ["HyperCertMetadata"]; 36 | -------------------------------------------------------------------------------- /deploy/003_Upgrade_SVG.ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | const oldSVG = await get("HyperCertSVG"); 10 | const newSVG = await ethers.getContractFactory("HyperCertSVG"); 11 | const updatedSVG = await upgrades.upgradeProxy(oldSVG.address, newSVG); 12 | 13 | const artifact = await deployments.getExtendedArtifact("HyperCertSVG"); 14 | const proxyDeployments = { 15 | address: updatedSVG.address, 16 | ...artifact, 17 | }; 18 | 19 | await save("HyperCertSVG", proxyDeployments); 20 | console.log("Updated HyperCertSVG"); 21 | }; 22 | 23 | export default deploy; 24 | deploy.tags = ["svg", "updateSVG"]; 25 | deploy.dependencies = ["HyperCertSVG"]; 26 | -------------------------------------------------------------------------------- /deploy/004_UpgradeMetadata.ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | const oldMetadata = await get("HyperCertMetadata"); 10 | const newMetadata = await ethers.getContractFactory("HyperCertMetadata"); 11 | const updatedMetadata = await upgrades.upgradeProxy(oldMetadata.address, newMetadata); 12 | 13 | const artifact = await deployments.getExtendedArtifact("HyperCertMetadata"); 14 | const proxyDeployments = { 15 | address: updatedMetadata.address, 16 | ...artifact, 17 | }; 18 | 19 | await save("HyperCertMetadata", proxyDeployments); 20 | console.log("Updated HyperCertMetadata"); 21 | }; 22 | 23 | export default deploy; 24 | deploy.tags = ["update"]; 25 | deploy.dependencies = ["HyperCertMetadata"]; 26 | -------------------------------------------------------------------------------- /deploy/005_Upgrade_HyperCertMinter.ts: -------------------------------------------------------------------------------- 1 | import { ethers, upgrades } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deployments } = hre; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { save, get } = deployments; // The deployments field itself contains the deploy function. 8 | 9 | const oldMinter = await get("HyperCertMinter"); 10 | const newMinter = await ethers.getContractFactory("HyperCertMinter"); 11 | const updatedMinter = await upgrades.upgradeProxy(oldMinter.address, newMinter); 12 | 13 | const artifact = await deployments.getExtendedArtifact("HyperCertMinter"); 14 | const proxyDeployments = { 15 | address: updatedMinter.address, 16 | ...artifact, 17 | }; 18 | 19 | await save("HyperCertMinter", proxyDeployments); 20 | console.log("Updated HyperCertMinter"); 21 | }; 22 | 23 | export default deploy; 24 | deploy.tags = ["minter", "updateMinter"]; 25 | deploy.dependencies = ["HyperCertMinter"]; 26 | -------------------------------------------------------------------------------- /deploy/999_Init_Config.ts: -------------------------------------------------------------------------------- 1 | import { getNamedAccounts } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | import { SVGBackgrounds } from "../src/util/wellKnown"; 6 | 7 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 8 | const { execute, read } = hre.deployments; 9 | 10 | const { deployer } = await getNamedAccounts(); 11 | 12 | const colorsSize = await read("HyperCertSVG", { from: deployer }, "colorsCounter"); 13 | 14 | if (colorsSize == 0) { 15 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#F3556F", "#121933", "#D4BFFF"]); // 16 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#FFBFCA", "#FFFFFF", "#5500FF"]); // 17 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#25316D", "#121933", "#80E5D3"]); // 18 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#25316D", "#FFFFFF", "#F3556F"]); // 19 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#80E5D3", "#FFFFFF", "#121933"]); // 20 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#FEF5AC", "#FFFFFF", "#25316D"]); // 21 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#F3556F", "#121933", "#FFBFCA"]); // 22 | await execute("HyperCertSVG", { from: deployer, log: true }, "addColors", ["#5500FF", "#121933", "#FFCC00"]); // 23 | } 24 | 25 | const currentBackground = await read("HyperCertSVG", { from: deployer }, "backgrounds", 0); 26 | 27 | if (currentBackground.length == 0) { 28 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[0]); 29 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[1]); 30 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[2]); 31 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[3]); 32 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[4]); 33 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[5]); 34 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[6]); 35 | await execute("HyperCertSVG", { from: deployer, log: true }, "addBackground", SVGBackgrounds[7]); 36 | } 37 | }; 38 | 39 | export default deploy; 40 | deploy.tags = ["local", "staging", "init"]; 41 | deploy.dependencies = ["HyperCertSVG", "HyperCertMinter"]; 42 | deploy.runAtTheEnd = true; 43 | -------------------------------------------------------------------------------- /deploy/___ERC3525_Testing.ts: -------------------------------------------------------------------------------- 1 | import { getNamedAccounts } from "hardhat"; 2 | import { DeployFunction } from "hardhat-deploy/types"; 3 | import { HardhatRuntimeEnvironment } from "hardhat/types"; 4 | 5 | const deploy: DeployFunction = async function (hre: HardhatRuntimeEnvironment) { 6 | const { deploy } = hre.deployments; // we get the deployments and getNamedAccounts which are provided by hardhat-deploy. 7 | const { deployer } = await getNamedAccounts(); // The deployments field itself contains the deploy function. 8 | 9 | await deploy("ERC3525_Testing", { from: deployer }); 10 | }; 11 | 12 | export default deploy; 13 | deploy.tags = ["local"]; 14 | deploy.skip = async hre => hre.network.name !== "hardhat"; 15 | -------------------------------------------------------------------------------- /deployments/goerli/.chainId: -------------------------------------------------------------------------------- 1 | 5 -------------------------------------------------------------------------------- /examples/hypercert_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo impact", 3 | "image": "ipfs://QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR", 4 | "external_link": "http://example.com", 5 | "format_version": 0.1, 6 | "description": "built code v0.0.1", 7 | "prev_hypercert": "2mbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR" 8 | } 9 | -------------------------------------------------------------------------------- /hardhat.config.ts: -------------------------------------------------------------------------------- 1 | import "@nomiclabs/hardhat-ethers"; 2 | import "@nomiclabs/hardhat-etherscan"; 3 | import "@nomiclabs/hardhat-waffle"; 4 | import "@openzeppelin/hardhat-upgrades"; 5 | import "@typechain/hardhat"; 6 | import { config as dotenvConfig } from "dotenv"; 7 | import "hardhat-abi-exporter"; 8 | import "hardhat-contract-sizer"; 9 | import "hardhat-deploy"; 10 | import "hardhat-gas-reporter"; 11 | import type { HardhatUserConfig } from "hardhat/config"; 12 | import type { NetworkUserConfig } from "hardhat/types"; 13 | import { resolve } from "path"; 14 | import "solidity-coverage"; 15 | import "solidity-docgen"; 16 | 17 | const dotenvConfigPath: string = process.env.DOTENV_CONFIG_PATH || "./.env"; 18 | dotenvConfig({ path: resolve(__dirname, dotenvConfigPath) }); 19 | 20 | // Ensure that we have all the environment variables we need. 21 | const mnemonic: string | undefined = process.env.MNEMONIC; 22 | if (!mnemonic) { 23 | throw new Error("Please set your MNEMONIC in a .env file"); 24 | } 25 | 26 | const infuraApiKey: string | undefined = process.env.INFURA_API_KEY; 27 | if (!infuraApiKey) { 28 | throw new Error("Please set your INFURA_API_KEY in a .env file"); 29 | } 30 | 31 | const chainIds = { 32 | goerli: 5, 33 | hardhat: 31337, 34 | mainnet: 1, 35 | }; 36 | 37 | function getChainConfig(chain: keyof typeof chainIds): NetworkUserConfig { 38 | const jsonRpcUrl = "https://" + chain + ".infura.io/v3/" + infuraApiKey; 39 | 40 | return { 41 | accounts: { 42 | count: 10, 43 | mnemonic, 44 | path: "m/44'/60'/0'/0", 45 | }, 46 | chainId: chainIds[chain], 47 | url: jsonRpcUrl, 48 | saveDeployments: true, 49 | }; 50 | } 51 | 52 | const config: HardhatUserConfig = { 53 | contractSizer: { 54 | alphaSort: true, 55 | disambiguatePaths: false, 56 | runOnCompile: true, 57 | strict: true, 58 | }, 59 | defaultNetwork: "hardhat", 60 | docgen: { 61 | exclude: ["mocks"], 62 | pages: "single", 63 | }, 64 | etherscan: { 65 | apiKey: { 66 | mainnet: process.env.ETHERSCAN_API_KEY || "", 67 | goerli: process.env.ETHERSCAN_API_KEY || "", 68 | }, 69 | }, 70 | gasReporter: { 71 | currency: "USD", 72 | coinmarketcap: process.env.CMC_API_KEY || "", 73 | enabled: process.env.REPORT_GAS ? true : false, 74 | excludeContracts: ["mocks"], 75 | src: "./contracts", 76 | }, 77 | namedAccounts: { 78 | deployer: 0, 79 | user: 1, 80 | anon: 9, 81 | }, 82 | networks: { 83 | hardhat: { 84 | accounts: { 85 | mnemonic, 86 | }, 87 | chainId: chainIds.hardhat, 88 | saveDeployments: true, 89 | }, 90 | goerli: { ...getChainConfig("goerli"), tags: ["staging"] }, 91 | mainnet: { ...getChainConfig("mainnet"), tags: ["production"] }, 92 | }, 93 | paths: { 94 | artifacts: "./artifacts", 95 | cache: "./cache", 96 | sources: "./contracts", 97 | tests: "./test", 98 | }, 99 | solidity: { 100 | version: "0.8.17", 101 | settings: { 102 | metadata: { 103 | // Not including the metadata hash 104 | // https://github.com/paulrberg/hardhat-template/issues/31 105 | bytecodeHash: "none", 106 | }, 107 | // Disable the optimizer when debugging 108 | // https://hardhat.org/hardhat-network/#solidity-optimizer-support 109 | optimizer: { 110 | enabled: true, 111 | runs: 100, 112 | details: { yul: true }, 113 | }, 114 | }, 115 | }, 116 | typechain: { 117 | outDir: "src/types", 118 | target: "ethers-v5", 119 | }, 120 | }; 121 | 122 | export default config; 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@protocol/hypercerts-protocol", 3 | "description": "EVM compatible protocol for managing impact claims", 4 | "version": "0.0.1", 5 | "author": { 6 | "name": "PL,GC,RC", 7 | "url": "https://github.com/protocol/hypercerts-protocol" 8 | }, 9 | "devDependencies": { 10 | "@commitlint/cli": "^17.1.2", 11 | "@commitlint/config-conventional": "^17.1.0", 12 | "@ethersproject/abi": "^5.7.0", 13 | "@ethersproject/abstract-signer": "^5.7.0", 14 | "@ethersproject/bignumber": "^5.7.0", 15 | "@ethersproject/bytes": "^5.7.0", 16 | "@ethersproject/providers": "^5.7.1", 17 | "@faker-js/faker": "^7.5.0", 18 | "@nomicfoundation/hardhat-network-helpers": "^1.0.6", 19 | "@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers@^0.3.0-beta.13", 20 | "@nomiclabs/hardhat-etherscan": "^3.1.0", 21 | "@nomiclabs/hardhat-waffle": "^2.0.3", 22 | "@openzeppelin/contracts-upgradeable": "^4.7.3", 23 | "@openzeppelin/hardhat-upgrades": "^1.20.0", 24 | "@trivago/prettier-plugin-sort-imports": "^3.3.0", 25 | "@typechain/ethers-v5": "^10.1.0", 26 | "@typechain/hardhat": "^6.1.3", 27 | "@types/chai": "^4.3.3", 28 | "@types/date-fns": "^2.6.0", 29 | "@types/fs-extra": "^9.0.13", 30 | "@types/libxmljs": "^0.18.8", 31 | "@types/mocha": "^9.1.1", 32 | "@types/node": "^18.7.18", 33 | "@typescript-eslint/eslint-plugin": "^5.38.0", 34 | "@typescript-eslint/parser": "^5.38.0", 35 | "chai": "^4.3.6", 36 | "commitizen": "^4.2.5", 37 | "cross-env": "^7.0.3", 38 | "cz-conventional-changelog": "^3.3.0", 39 | "dotenv": "^16.0.2", 40 | "eslint": "^8.23.1", 41 | "eslint-config-prettier": "^8.5.0", 42 | "ethereum-waffle": "^3.4.4", 43 | "ethers": "^5.7.1", 44 | "fs-extra": "^10.1.0", 45 | "hardhat": "^2.11.2", 46 | "hardhat-abi-exporter": "^2.10.0", 47 | "hardhat-contract-sizer": "^2.6.1", 48 | "hardhat-deploy": "^0.11.15", 49 | "hardhat-deploy-ethers": "^0.3.0-beta.13", 50 | "hardhat-gas-reporter": "^1.0.9", 51 | "husky": "^8.0.1", 52 | "libxmljs": "^0.19.10", 53 | "lint-staged": "^13.0.3", 54 | "lodash": "^4.17.21", 55 | "mocha": "^10.0.0", 56 | "pinst": "^3.0.0", 57 | "prettier": "^2.7.1", 58 | "prettier-plugin-solidity": "^1.0.0-beta.24", 59 | "shx": "^0.3.4", 60 | "solhint": "^3.3.7", 61 | "solhint-plugin-prettier": "^0.0.5", 62 | "solidity-coverage": "^0.8.2", 63 | "solidity-docgen": "^0.6.0-beta.25", 64 | "ts-generator": "^0.1.1", 65 | "ts-node": "^10.9.1", 66 | "typechain": "^8.1.0", 67 | "typescript": "^4.8.3" 68 | }, 69 | "files": [ 70 | "/contracts" 71 | ], 72 | "keywords": [ 73 | "blockchain", 74 | "ethers", 75 | "ethereum", 76 | "hardhat", 77 | "smart-contracts", 78 | "solidity", 79 | "template", 80 | "typescript", 81 | "typechain" 82 | ], 83 | "packageManager": "yarn@3.2.1", 84 | "publishConfig": { 85 | "access": "public" 86 | }, 87 | "scripts": { 88 | "clean": "shx rm -rf ./artifacts ./cache ./coverage ./src/types ./coverage.json && yarn typechain", 89 | "commit": "git-cz", 90 | "compile": "cross-env TS_NODE_TRANSPILE_ONLY=true hardhat compile && hardhat docgen", 91 | "coverage": "hardhat coverage --solcoverjs ./.solcover.js --temp artifacts --testfiles \"test/**/*.ts\" && yarn typechain", 92 | "deploy": "hardhat deploy", 93 | "docgen": "hardhat docgen", 94 | "lint": "yarn lint:sol && yarn lint:ts && yarn prettier:check", 95 | "lint:sol": "solhint --config ./.solhint.json --max-warnings 0 \"contracts/**/*.sol\"", 96 | "lint:ts": "eslint --config ./.eslintrc.yml --ignore-path ./.eslintignore --ext .js,.ts .", 97 | "postinstall": "husky install && DOTENV_CONFIG_PATH=./.env.example yarn typechain", 98 | "postpublish": "pinst --enable", 99 | "prepublishOnly": "pinst --disable", 100 | "prettier": "prettier --config ./.prettierrc.yml --write \"**/*.{js,json,md,sol,ts,yml}\"", 101 | "prettier:check": "prettier --check --config ./.prettierrc.yml \"**/*.{js,json,md,sol,ts,yml}\"", 102 | "test": "hardhat test", 103 | "typechain": "cross-env TS_NODE_TRANSPILE_ONLY=true hardhat typechain" 104 | }, 105 | "dependencies": { 106 | "chai-string": "^1.5.0" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/xsd/namespace.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/xsd/xlink.xsd: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Intended for use as the type of user-declared elements to make them 107 | simple links. 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | Intended for use as the type of user-declared elements to make them 133 | extended links. 134 | Note that the elements referenced in the content model are all abstract. 135 | The intention is that by simply declaring elements with these as their 136 | substitutionGroup, all the right things will happen. 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | xml:lang is not required, but provides much of the 151 | motivation for title elements in addition to attributes, and so 152 | is provided here for convenience. 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | label is not required, but locators have no particular 200 | XLink function if they are not labeled. 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | from and to have default behavior when values are missing 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.allowances.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | import { setupTestERC3525 } from "../setup"; 5 | 6 | export function shouldBehaveLikeSemiFungibleTokenAllowances(): void { 7 | describe("ERC3525 allows for multiple levels of allowances", () => { 8 | it("allows for allowance on a specific token", async function () { 9 | const { sft, user, anon, deployer } = await setupTestERC3525(); 10 | 11 | await deployer.sft.mintValue(user.address, 1, 1_000_000); 12 | await deployer.sft.mintValue(anon.address, 1, 1_000_000); 13 | expect(await sft.getApproved(1)).to.be.eq(ethers.constants.AddressZero); 14 | 15 | await expect(anon.sft["transferFrom(uint256,uint256,uint256)"](1, 2, 100_000)).to.be.revertedWith( 16 | "InsufficientAllowance(100000, 0)", 17 | ); 18 | 19 | await expect(sft["approve(address,uint256)"](anon.address, 1)).to.be.revertedWith("NotApprovedOrOwner()"); 20 | await expect(user.sft["approve(address,uint256)"](user.address, 1)).to.be.revertedWith( 21 | `InvalidApproval(1, "${user.address}", "${user.address}")`, 22 | ); 23 | 24 | await expect(user.sft["approve(address,uint256)"](anon.address, 1)) 25 | .to.emit(sft, "Approval") 26 | .withArgs(user.address, anon.address, 1); 27 | 28 | expect(await sft.getApproved(1)).to.be.eq(anon.address); 29 | 30 | await expect(anon.sft["transferFrom(uint256,uint256,uint256)"](1, 2, 100_000)) 31 | .to.emit(sft, "TransferValue") 32 | .withArgs(1, 2, 100_000); 33 | }); 34 | 35 | it("allows for allowance on a specific token's value", async function () { 36 | const { sft, user, anon, deployer } = await setupTestERC3525(); 37 | 38 | await user.sft.mintValue(user.address, 1, 1_000_000); 39 | await anon.sft.mintValue(anon.address, 1, 1_000_000); 40 | expect(await sft.getApproved(1)).to.be.eq(ethers.constants.AddressZero); 41 | 42 | await expect(anon.sft["transferFrom(uint256,uint256,uint256)"](1, 2, 100_000)).to.be.revertedWith( 43 | "InsufficientAllowance(100000, 0)", 44 | ); 45 | 46 | // Custom errors 47 | await expect(deployer.sft["approve(uint256,address,uint256)"](1, anon.address, 500_000)).to.be.revertedWith( 48 | "NotApprovedOrOwner", 49 | ); 50 | await expect(user.sft["approve(uint256,address,uint256)"](1, user.address, 500_000)).to.be.revertedWith( 51 | `InvalidApproval(1, "${user.address}", "${user.address}")`, 52 | ); 53 | 54 | await expect(user.sft["approve(uint256,address,uint256)"](1, anon.address, 500_000)) 55 | .to.emit(sft, "ApprovalValue") 56 | .withArgs(1, anon.address, 500_000); 57 | 58 | expect(await sft.allowance(1, anon.address)).to.be.eq(500_000); 59 | 60 | await expect(anon.sft["transferFrom(uint256,uint256,uint256)"](1, 2, 500_001)).to.be.revertedWith( 61 | "InsufficientAllowance", 62 | ); 63 | await expect(anon.sft["transferFrom(uint256,uint256,uint256)"](1, 2, 500_000)) 64 | .to.emit(sft, "TransferValue") 65 | .withArgs(1, 2, 500_000); 66 | }); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.approvals.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ContractTransaction } from "ethers"; 3 | import { ethers } from "hardhat"; 4 | 5 | import { ERC3525_Testing } from "../../src/types"; 6 | import { setupTestERC3525 } from "../setup"; 7 | 8 | type AddressedERC3525 = { 9 | sft: ERC3525_Testing; 10 | address: string; 11 | }; 12 | 13 | export const shouldBehaveLikeSemiFungibleTokenApprovals = () => { 14 | describe("ERC3525 allows for approvals", () => { 15 | const tokenId1 = 1; 16 | const tokenId2 = 2; 17 | const tokenId3 = 200; 18 | const slot = 1; 19 | 20 | it("reverts isApprovedOrOwner check on non-existent token", async () => { 21 | const { sft, user } = await setupTestERC3525(); 22 | await expect(sft.isApprovedOrOwner(user.address, 1)).to.be.revertedWith("NonExistentToken"); 23 | }); 24 | 25 | const testCases = < 26 | [string, (user: AddressedERC3525, anon: AddressedERC3525) => Promise, string, string][] 27 | >[ 28 | ["burn", (_user, anon) => anon.sft.burn(tokenId1), "NotApprovedOrOwner", "Transfer"], 29 | [ 30 | "transfer value", 31 | (_user, anon) => anon.sft["transferFrom(uint256,uint256,uint256)"](tokenId1, tokenId2, 500_000), 32 | "InsufficientAllowance(500000, 0)", 33 | "TransferValue", 34 | ], 35 | [ 36 | "transfer token", 37 | (user, anon) => anon.sft["transferFrom(address,address,uint256)"](user.address, anon.address, tokenId1), 38 | "NotApprovedOrOwner", 39 | "Transfer", 40 | ], 41 | ]; 42 | 43 | testCases.forEach(([name, fn, revertMessage, successEvent]) => { 44 | it(`allows approval for specific token - ${name}`, async () => { 45 | const { sft, user, anon } = await setupTestERC3525(); 46 | 47 | await sft.mintValue(user.address, slot, 1_000_000); 48 | await sft.mintValue(anon.address, slot, 1_000_000); 49 | 50 | expect(await sft.getApproved(tokenId1)).to.be.eq(ethers.constants.AddressZero); 51 | 52 | // Custom errors 53 | await expect(sft.getApproved(tokenId3)).to.be.revertedWith("NonExistentToken"); 54 | await expect(user.sft["approve(address,uint256)"](user.address, tokenId1)).to.be.revertedWith( 55 | "InvalidApproval", 56 | ); 57 | await expect(user.sft["approve(address,uint256)"](user.address, tokenId3)).to.be.revertedWith( 58 | "NonExistentToken", 59 | ); 60 | await expect(anon.sft["approve(address,uint256)"](anon.address, tokenId1)).to.be.revertedWith( 61 | "NotApprovedOrOwner", 62 | ); 63 | 64 | await expect(fn(user, anon)).to.be.revertedWith(revertMessage); 65 | 66 | await expect(user.sft["approve(address,uint256)"](anon.address, tokenId1)) 67 | .to.emit(sft, "Approval") 68 | .withArgs(user.address, anon.address, tokenId1); 69 | 70 | expect(await sft.getApproved(tokenId1)).to.be.eq(anon.address); 71 | 72 | await expect(fn(user, anon)).to.emit(sft, successEvent); 73 | }); 74 | 75 | testCases.forEach(([name, fn, revertMessage, successEvent]) => { 76 | it(`allows approval for all - ${name}`, async () => { 77 | const { sft, user, anon } = await setupTestERC3525(); 78 | 79 | await user.sft.mintValue(user.address, slot, 1_000_000); 80 | await user.sft.mintValue(user.address, slot, 1_000_000); 81 | expect(await sft.isApprovedForAll(user.address, anon.address)).to.be.false; 82 | 83 | // Custom errors 84 | await expect(user.sft.setApprovalForAll(user.address, true)).to.be.revertedWith("InvalidApproval"); 85 | 86 | await expect(fn(user, anon)).to.be.revertedWith(revertMessage); 87 | 88 | await expect(user.sft.setApprovalForAll(anon.address, true)) 89 | .to.emit(sft, "ApprovalForAll") 90 | .withArgs(user.address, anon.address, true); 91 | 92 | expect(await sft.isApprovedForAll(user.address, anon.address)).to.be.true; 93 | 94 | await expect(fn(user, anon)).to.emit(sft, successEvent); 95 | }); 96 | }); 97 | }); 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.burn.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { setupTestERC3525 } from "../setup"; 4 | 5 | export function shouldBehaveLikeSemiFungibleTokenBurn(): void { 6 | describe("ERC3525 allows for burning specific tokens", () => { 7 | it("allows for burning a specific token the callers owns", async function () { 8 | const { sft, user } = await setupTestERC3525(); 9 | 10 | await sft.mintValue(user.address, 1, 1_000_000); 11 | 12 | const tokenId = 1; 13 | await expect(sft.burn(tokenId)).to.be.revertedWith("NotApprovedOrOwner"); 14 | //TODO check token allocation enumeration 15 | await expect(user.sft.burn(tokenId)).to.emit(sft, "SlotChanged").withArgs(1, 1, 0); 16 | 17 | await expect(sft.ownerOf(tokenId)).to.be.revertedWith("NonExistentToken"); 18 | }); 19 | 20 | it("does not allow burning other tokens in the same slot the caller does not own", async function () { 21 | const { sft, user, anon } = await setupTestERC3525(); 22 | 23 | await sft.mintValue(user.address, 1, 1_000_000); 24 | await sft.mintValue(anon.address, 1, 1_000_000); 25 | 26 | await expect(user.sft.burn(2)).to.be.revertedWith("NotApprovedOrOwner"); 27 | }); 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.mint.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | import { setupTestERC3525 } from "../setup"; 5 | 6 | export function shouldBehaveLikeSemiFungibleTokenMint(): void { 7 | describe("ERC3525 allows for minting on specific slots with values", () => { 8 | it("allows for minting a single token in a slot with a given value", async function () { 9 | const { sft, user } = await setupTestERC3525(); 10 | 11 | await expect(sft["balanceOf(uint256)"](0)).to.be.revertedWith("NonExistentToken"); 12 | await expect(sft.slotOf(0)).to.be.revertedWith("NonExistentToken"); 13 | await expect(sft.mintValue(ethers.constants.AddressZero, 0, 1_000_000)).to.be.revertedWith("ToZeroAddress"); 14 | 15 | await expect(sft.mintValue(user.address, 1, 1_000_000)).to.emit(sft, "TransferValue").withArgs(0, 1, 1_000_000); 16 | await expect(sft.mintValue(user.address, 1, 1_000_000)).to.emit(sft, "TransferValue").withArgs(0, 2, 1_000_000); 17 | 18 | expect(await sft.totalSupply()).to.be.eq(2); 19 | expect(await sft.tokenByIndex(0)).to.be.eq(1); 20 | expect(await sft.tokenByIndex(1)).to.be.eq(2); 21 | expect(await sft.tokenOfOwnerByIndex(user.address, 0)).to.be.eq(1); 22 | expect(await sft.tokenOfOwnerByIndex(user.address, 1)).to.be.eq(2); 23 | 24 | expect(await sft["tokenSupplyInSlot"](1)).to.be.eq(2); 25 | expect(await sft["tokenInSlotByIndex"](1, 0)).to.be.eq(1); 26 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq(1000000); 27 | }); 28 | 29 | it("allows for minting another token in a slot with a given value", async function () { 30 | const { sft, user } = await setupTestERC3525(); 31 | 32 | await expect(sft.mintValue(user.address, 1, 1_000_000)).to.emit(sft, "TransferValue").withArgs(0, 1, 1_000_000); 33 | 34 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("1000000"); 35 | await expect(sft["balanceOf(uint256)"](2)).to.be.revertedWith("NonExistentToken"); 36 | 37 | await expect(sft.mintValue(user.address, 1, 2_000_000)).to.emit(sft, "TransferValue").withArgs(0, 2, 2_000_000); 38 | 39 | expect(await sft.totalSupply()).to.be.eq(2); 40 | expect(await sft["tokenSupplyInSlot"](1)).to.be.eq("2"); 41 | expect(await sft["tokenInSlotByIndex"](1, 0)).to.be.eq("1"); 42 | expect(await sft["tokenInSlotByIndex"](1, 1)).to.be.eq("2"); 43 | 44 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("1000000"); 45 | expect(await sft["balanceOf(uint256)"](2)).to.be.eq("2000000"); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.miscellaneous.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | import { setupTestERC3525 } from "../setup"; 5 | 6 | export const shouldBehaveLikeSemiFungibleTokenMiscellaneous = () => { 7 | describe("ERC3525 miscellaneous", () => { 8 | it("reverts balance check on the zero address", async () => { 9 | const { sft } = await setupTestERC3525(); 10 | 11 | await expect(sft["balanceOf(address)"](ethers.constants.AddressZero)).to.be.revertedWith("ToZeroAddress"); 12 | }); 13 | 14 | it("reverts out-of-range tokenByIndex request", async () => { 15 | const { sft } = await setupTestERC3525(); 16 | 17 | await expect(sft.tokenByIndex(0)).to.be.revertedWith("InvalidID"); 18 | }); 19 | 20 | it("reverts out-of-range tokenOfOwnerByIndex request", async () => { 21 | const { sft, user } = await setupTestERC3525(); 22 | 23 | await expect(sft.tokenOfOwnerByIndex(user.address, 0)).to.be.revertedWith("InvalidID"); 24 | }); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.transfer.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { setupTestERC3525 } from "../setup"; 4 | 5 | export function shouldBehaveLikeSemiFungibleTokenTransfer(): void { 6 | describe("ERC3525 supports transfers on slot and token level", function () { 7 | it("allows for transfering tokens between addresses", async function () { 8 | const { sft, user, anon } = await setupTestERC3525(); 9 | await sft.mintValue(user.address, 1, 1_000_000); 10 | 11 | expect(await sft.ownerOf(1)).to.be.eq(user.address); 12 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("1000000"); 13 | 14 | await expect(user.sft["transferFrom(address,address,uint256)"](user.address, anon.address, 1)) 15 | .to.emit(sft, "Transfer") 16 | .withArgs(user.address, anon.address, 1); 17 | 18 | expect(await sft.ownerOf(1)).to.be.eq(anon.address); 19 | }); 20 | 21 | it("allows for transfering values between tokens with identical slots", async function () { 22 | const { sft, user } = await setupTestERC3525(); 23 | 24 | await expect(sft.transferValue(1, 2, 1_234_5678)).to.be.revertedWith("NonExistentToken(1)"); 25 | 26 | await sft.mintValue(user.address, 0, 1_000_000); 27 | 28 | await expect(sft.transferValue(1, 2, 500_000)).to.be.revertedWith("NonExistentToken(2)"); 29 | 30 | await sft.mintValue(user.address, 0, 2_000_000); 31 | 32 | await expect(sft.transferValue(1, 2, 8_796_543)).to.be.revertedWith("InsufficientBalance(8796543, 1000000)"); 33 | 34 | await expect(sft.transferValue(1, 2, 500_000)).to.emit(sft, "TransferValue").withArgs(1, 2, 500_000); 35 | 36 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("500000"); 37 | expect(await sft["balanceOf(uint256)"](2)).to.be.eq("2500000"); 38 | }); 39 | 40 | it("does not allow for transfering values between tokens with different slots", async function () { 41 | const { sft, user } = await setupTestERC3525(); 42 | await sft.mintValue(user.address, 1, 1_000_000); 43 | await sft.mintValue(user.address, 2, 2_000_000); 44 | 45 | await expect(sft.transferValue(1, 2, 500_000)).to.be.revertedWith("SlotsMismatch(1, 2)"); 46 | 47 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("1000000"); 48 | expect(await sft["balanceOf(uint256)"](2)).to.be.eq("2000000"); 49 | }); 50 | 51 | it("allows for transfering value from a token to an address", async function () { 52 | const { sft, user, anon } = await setupTestERC3525(); 53 | await sft.mintValue(user.address, 1, 1_000_000); 54 | 55 | expect(await sft.ownerOf(1)).to.be.eq(user.address); 56 | await expect(sft.ownerOf(2)).to.be.revertedWith("NonExistentToken"); 57 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("1000000"); 58 | await expect(sft["balanceOf(uint256)"](2)).to.be.revertedWith(`NonExistentToken`); 59 | 60 | await expect(user.sft["transferFrom(uint256,address,uint256)"](1, anon.address, 500_000)) 61 | .to.emit(sft, "TransferValue") 62 | .withArgs(1, 2, "500000"); 63 | 64 | expect(await sft.ownerOf(1)).to.be.eq(user.address); 65 | expect(await sft.ownerOf(2)).to.be.eq(anon.address); 66 | expect(await sft["balanceOf(uint256)"](1)).to.be.eq("500000"); 67 | expect(await sft["balanceOf(uint256)"](2)).to.be.eq("500000"); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /test/erc3525/ERC3525.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers, getNamedAccounts } from "hardhat"; 3 | 4 | import { ERC3525_Testing } from "../../src/types"; 5 | import { ERC3525 } from "../wellKnown"; 6 | import { shouldBehaveLikeSemiFungibleTokenAllowances } from "./ERC3525.allowances"; 7 | import { shouldBehaveLikeSemiFungibleTokenApprovals } from "./ERC3525.approvals"; 8 | import { shouldBehaveLikeSemiFungibleTokenBurn } from "./ERC3525.burn"; 9 | import { shouldBehaveLikeSemiFungibleTokenMint } from "./ERC3525.mint"; 10 | import { shouldBehaveLikeSemiFungibleTokenMiscellaneous } from "./ERC3525.miscellaneous"; 11 | import { shouldBehaveLikeSemiFungibleTokenTransfer } from "./ERC3525.transfer"; 12 | 13 | describe("Unit tests", function () { 14 | describe("ERC3525", function () { 15 | it("is an initializable ERC3525 contract", async () => { 16 | const tokenFactory = await ethers.getContractFactory(ERC3525); 17 | const tokenInstance = await tokenFactory.deploy(); 18 | 19 | // 0x01ffc9a7 is the ERC165 interface identifier for EIP165 - interfaces 20 | expect(await tokenInstance.supportsInterface("0x01ffc9a7")).to.be.true; 21 | 22 | // 0xd9b67a26 is the ERC165 interface identifier for EIP3525 - SFT 23 | expect(await tokenInstance.supportsInterface("0xd5358140")).to.be.true; 24 | 25 | // 0x80ac58cd is the ERC165 interface identifier for EIP721 - backward compatible with NFT 26 | expect(await tokenInstance.supportsInterface("0x80ac58cd")).to.be.true; 27 | 28 | await expect(tokenInstance.initialize()).to.be.revertedWith("Initializable: contract is already initialized"); 29 | }); 30 | 31 | it("supports enumerable slots", async () => { 32 | const tokenFactory = await ethers.getContractFactory(ERC3525); 33 | const tokenInstance = await tokenFactory.deploy(); 34 | 35 | // 0x3b741b9e is the ERC165 interface identifier for IERC3525SlotEnumerable 36 | expect(await tokenInstance.supportsInterface("0x3b741b9e")).to.be.true; 37 | 38 | expect(await tokenInstance.slotCount()).to.eq(0); 39 | await expect(tokenInstance.slotByIndex(0)).to.be.reverted; 40 | expect(await tokenInstance.tokenSupplyInSlot(0)).to.eq(0); 41 | await expect(tokenInstance.tokenInSlotByIndex(0, 0)).to.be.reverted; 42 | }); 43 | 44 | it("supports ERC3525 metadata", async () => { 45 | const tokenFactory = await ethers.getContractFactory(ERC3525); 46 | const tokenInstance = await tokenFactory.deploy(); 47 | const { deployer } = await getNamedAccounts(); 48 | 49 | // 0xe1600902 is the ERC165 interface identifier for IERC3525Metadata 50 | expect(await tokenInstance.supportsInterface("0xe1600902")).to.be.true; 51 | 52 | expect(await tokenInstance.contractURI().then((res: string) => res.startsWith(`data:application/json;`))).to.be 53 | .true; 54 | expect(await tokenInstance.slotURI(0).then((res: string) => res.startsWith(`data:application/json;`))).to.be.true; 55 | 56 | await tokenInstance.mintValue(deployer, 12345, 10000); 57 | expect(await tokenInstance.tokenURI(1).then((res: string) => res.startsWith(`data:application/json;`))).to.be 58 | .true; 59 | }); 60 | 61 | shouldBehaveLikeSemiFungibleTokenMint(); 62 | shouldBehaveLikeSemiFungibleTokenTransfer(); 63 | shouldBehaveLikeSemiFungibleTokenBurn(); 64 | shouldBehaveLikeSemiFungibleTokenAllowances(); 65 | shouldBehaveLikeSemiFungibleTokenApprovals(); 66 | shouldBehaveLikeSemiFungibleTokenMiscellaneous(); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /test/hypercert_metadata/HyperCertMetadata.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers, getNamedAccounts } from "hardhat"; 3 | 4 | import { HyperCertMetadata as Metadata } from "../../src/types"; 5 | import { setupTestMetadata } from "../setup"; 6 | import { DEFAULT_ADMIN_ROLE, HyperCertMetadata, UPGRADER_ROLE } from "../wellKnown"; 7 | 8 | describe("Unit tests", function () { 9 | describe(HyperCertMetadata, function () { 10 | it("is an initializable contract", async () => { 11 | const tokenFactory = await ethers.getContractFactory(HyperCertMetadata); 12 | const tokenInstance = await tokenFactory.deploy(); 13 | const { anon } = await getNamedAccounts(); 14 | 15 | await expect(tokenInstance.initialize(anon)).to.be.revertedWith("Initializable: contract is already initialized"); 16 | }); 17 | 18 | it("is a UUPS-upgradeable contract", async () => { 19 | const { sft } = await setupTestMetadata(); 20 | 21 | await expect(sft.proxiableUUID()).to.be.revertedWith("UUPSUpgradeable: must not be called through delegatecall"); 22 | }); 23 | 24 | const roles = <[string, string][]>[ 25 | ["admin", DEFAULT_ADMIN_ROLE], 26 | ["upgrader", UPGRADER_ROLE], 27 | ]; 28 | 29 | roles.forEach(([name, role]) => { 30 | it(`supports ${name} role`, async function () { 31 | const { sft, user, deployer } = await setupTestMetadata(); 32 | 33 | expect(await sft.hasRole(role, deployer.address)).to.be.true; 34 | expect(await sft.hasRole(role, user.address)).to.be.false; 35 | 36 | await expect(user.sft.grantRole(role, user.address)).to.be.revertedWith( 37 | `AccessControl: account ${user.address.toLowerCase()} is missing role ${DEFAULT_ADMIN_ROLE}`, 38 | ); 39 | 40 | await expect(deployer.sft.grantRole(role, user.address)) 41 | .to.emit(sft, "RoleGranted") 42 | .withArgs(role, user.address, deployer.address); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.burning.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | import setupTest from "../setup"; 5 | import { encodeClaim, newClaim } from "../utils"; 6 | 7 | export function shouldBehaveLikeHypercertMinterBurning(): void { 8 | it("allows burning when the creator owns the full slot", async function () { 9 | const { deployer, minter } = await setupTest(); 10 | const claim = await newClaim(); 11 | const slot = 1; 12 | const data = encodeClaim(claim); 13 | const tokenId = 1; 14 | 15 | await expect(deployer.minter.mint(deployer.address, data)).to.emit(minter, "ImpactClaimed"); 16 | 17 | expect(await minter["balanceOf(address)"](deployer.address)).to.equal(1); 18 | expect(await minter["balanceOf(uint256)"](tokenId)).to.equal(claim.fractions[0]); 19 | expect(await minter.ownerOf(tokenId)).to.be.eq(deployer.address); 20 | expect(await minter.slotOf(1)).to.be.eq(slot); 21 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(1); 22 | 23 | await expect(deployer.minter.burn(tokenId)) 24 | .to.emit(minter, "Transfer") 25 | .withArgs(deployer.address, ethers.constants.AddressZero, tokenId) 26 | .to.emit(minter, "TransferValue") 27 | .withArgs(tokenId, 0, 100) 28 | .to.emit(minter, "SlotChanged") 29 | .withArgs(tokenId, slot, 0); 30 | 31 | expect(await deployer.minter["balanceOf(address)"](deployer.address)).to.equal(0); 32 | await expect(minter["balanceOf(uint256)"](tokenId)).to.be.revertedWith("NonExistentToken"); 33 | await expect(minter.ownerOf(tokenId)).to.be.revertedWith("NonExistentToken"); 34 | await expect(minter.slotOf(tokenId)).to.be.revertedWith("NonExistentToken"); 35 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(0); 36 | await expect(minter.tokenURI(tokenId)).to.be.revertedWith("NonExistentToken"); 37 | await expect(minter.slotURI(slot)).to.be.revertedWith("NonExistentSlot"); 38 | }); 39 | 40 | it("prevents burning when the creator doesn't own the full slot", async function () { 41 | const { deployer, minter, user } = await setupTest(); 42 | const claim = await newClaim({ fractions: [50, 50] }); 43 | const slot = 1; 44 | const data = encodeClaim(claim); 45 | 46 | await expect(deployer.minter.mint(deployer.address, data)).to.emit(minter, "ImpactClaimed"); 47 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(2); 48 | expect(await minter["balanceOf(address)"](deployer.address)).to.equal(2); 49 | 50 | await expect(deployer.minter["transferFrom(address,address,uint256)"](deployer.address, user.address, 1)).to.emit( 51 | minter, 52 | "Transfer", 53 | ); 54 | 55 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(2); 56 | expect(await minter["balanceOf(address)"](deployer.address)).to.equal(1); 57 | expect(await minter["balanceOf(address)"](user.address)).to.equal(1); 58 | 59 | await expect(deployer.minter.burn(1)).to.be.revertedWith("InsufficientBalance(100, 50)"); 60 | 61 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(2); 62 | expect(await minter["balanceOf(address)"](deployer.address)).to.equal(1); 63 | expect(await minter["balanceOf(address)"](user.address)).to.equal(1); 64 | }); 65 | 66 | it("prevents burning when the owner isn't the creator", async function () { 67 | const { deployer, minter, anon } = await setupTest(); 68 | const claim = await newClaim({ fractions: [100] }); 69 | const slot = 1; 70 | const data = encodeClaim(claim); 71 | 72 | await expect(deployer.minter.mint(anon.address, data)).to.emit(minter, "ImpactClaimed"); 73 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(1); 74 | expect(await minter["balanceOf(address)"](anon.address)).to.equal(1); 75 | 76 | await expect(anon.minter.burn(1)).to.be.revertedWith("NotApprovedOrOwner()"); 77 | await expect(deployer.minter.burn(1)).to.be.revertedWith("NotApprovedOrOwner()"); 78 | 79 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(1); 80 | expect(await minter["balanceOf(address)"](anon.address)).to.equal(1); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.integration.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import setupTest from "../setup"; 4 | import { encodeClaim, newClaim } from "../utils"; 5 | 6 | export function shouldBehaveLikeHypercertMinterIntegration(): void { 7 | it("supports the full minting, splitting, merging, burning flow", async function () { 8 | const { deployer, minter } = await setupTest(); 9 | const claim = await newClaim({ fractions: [100, 200, 300] }); 10 | const data = encodeClaim(claim); 11 | 12 | await expect(deployer.minter.mint(deployer.address, data)).to.emit(minter, "ImpactClaimed"); 13 | await expect(deployer.minter.split(2, [100, 100])).to.emit(minter, "TransferValue"); 14 | expect(await deployer.minter["balanceOf(uint256)"](2)).to.be.eq("100"); 15 | 16 | await expect(deployer.minter.merge([2, 3])).to.emit(minter, "TransferValue"); 17 | await expect(deployer.minter["balanceOf(uint256)"](2)).to.be.revertedWith("NonExistentToken"); 18 | expect(await deployer.minter["balanceOf(uint256)"](3)).to.be.eq("400"); 19 | 20 | await expect(deployer.minter.split(3, [100, 100, 200])).to.emit(minter, "TransferValue"); 21 | 22 | const secondClaim = await newClaim({ workTimeframe: [12345678, 87654321], fractions: [1000, 2000, 3000] }); 23 | const secondData = encodeClaim(secondClaim); 24 | 25 | await expect(deployer.minter.mint(deployer.address, secondData)).to.emit(minter, "ImpactClaimed"); 26 | await expect(deployer.minter.split(7, [500, 500])).to.emit(minter, "TransferValue"); 27 | }); 28 | 29 | it("supports minting the same claim after it's been burned", async function () { 30 | const { deployer, minter, user, anon } = await setupTest(); 31 | const claim = await newClaim({ contributors: [deployer.address, user.address, anon.address], fractions: [100] }); 32 | const data = encodeClaim(claim); 33 | 34 | await expect(deployer.minter.mint(deployer.address, data)).to.emit(minter, "ImpactClaimed"); 35 | expect(await deployer.minter["balanceOf(uint256)"](1)).to.be.eq("100"); 36 | 37 | await expect(deployer.minter.mint(deployer.address, data)).to.be.revertedWith("ConflictingClaim()"); 38 | 39 | await expect(deployer.minter.burn(1)).to.emit(minter, "SlotChanged"); 40 | 41 | await expect(deployer.minter.mint(deployer.address, data)).to.emit(minter, "ImpactClaimed"); 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.minting.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import { expect } from "chai"; 3 | import { ethers } from "hardhat"; 4 | 5 | import setupTest, { setupImpactScopes, setupWorkScopes } from "../setup"; 6 | import { 7 | Claim, 8 | compareClaimAgainstInput, 9 | encodeClaim, 10 | getEncodedImpactClaim, 11 | newClaim, 12 | randomScopes, 13 | subScopeKeysForValues, 14 | validateMetadata, 15 | } from "../utils"; 16 | import { ImpactScopes, Rights, WorkScopes } from "../wellKnown"; 17 | 18 | export function shouldBehaveLikeHypercertMinterMinting(): void { 19 | it("anybody can mint an impact claim with 1 or more fractions - except zero-address", async function () { 20 | const { deployer, minter } = await setupTest(); 21 | 22 | const workScopes = Object.keys(WorkScopes).slice(0, 1); 23 | const claim1 = await newClaim({ 24 | name: "Impact claim simple minting test", 25 | workScopes: workScopes, 26 | fractions: [100], 27 | }); 28 | const data1 = encodeClaim(claim1); 29 | const claimID = 1; 30 | const data2 = await getEncodedImpactClaim({ workTimeframe: [234567890, 123456789] }); 31 | const data3 = await getEncodedImpactClaim({ impactTimeframe: [1087654321, 987654321] }); 32 | const data4 = await getEncodedImpactClaim({ impactTimeframe: [108765432, 109999432] }); 33 | 34 | // Empty data 35 | await expect(deployer.minter.mint(deployer.address, "0x")).to.be.revertedWith("EmptyInput"); 36 | // Invalid workTimeframe 37 | await expect(deployer.minter.mint(deployer.address, data2)).to.be.revertedWith("InvalidTimeframe"); 38 | // Invalid impactTimeframe 39 | await expect(deployer.minter.mint(deployer.address, data3)).to.be.revertedWith("InvalidTimeframe"); 40 | // Invalid impactTimeframe 41 | await expect(deployer.minter.mint(deployer.address, data4)).to.be.revertedWith("InvalidTimeframe"); 42 | 43 | await expect(minter.ownerOf(1)).to.be.revertedWith("NonExistentToken"); 44 | await expect(minter.slotOf(1)).to.be.revertedWith("NonExistentToken"); 45 | await expect(minter["balanceOf(uint256)"](1)).to.be.revertedWith("NonExistentToken"); 46 | 47 | // Supply 100, 1 fraction, single slot 48 | await expect(deployer.minter.mint(deployer.address, data1)) 49 | .to.emit(minter, "Transfer") 50 | .withArgs(ethers.constants.AddressZero, deployer.address, 1) 51 | .to.emit(minter, "SlotChanged") 52 | .withArgs(1, 0, claimID) 53 | .to.emit(minter, "ImpactClaimed") 54 | .withArgs(claimID, deployer.address, claim1.fractions); 55 | 56 | expect(await minter.ownerOf(1)).to.be.eq(deployer.address); 57 | expect(await minter.slotOf(1)).to.be.eq(claimID); 58 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(1); 59 | expect(await minter["balanceOf(uint256)"](1)).to.be.eq(100); 60 | const claim1Subbed = subScopeKeysForValues(claim1, ImpactScopes); 61 | await validateMetadata(await minter.tokenURI(1), claim1Subbed, claim1.fractions[0]); 62 | await validateMetadata(await minter.slotURI(claimID), claim1Subbed); 63 | 64 | await expect(deployer.minter.mint(ethers.constants.AddressZero, data1)).to.be.revertedWith("ToZeroAddress"); 65 | }); 66 | 67 | it("anybody can mint an impact claim with multiple fractions - except zero-address", async function () { 68 | const { deployer, minter, user } = await setupTest(); 69 | 70 | const workScopes = Object.keys(WorkScopes).slice(1, 2); 71 | const claim = await newClaim({ workScopes, fractions: [50, 50] }); 72 | const data = encodeClaim(claim); 73 | const claimID = 1; 74 | 75 | // Supply 100, 2 fractions, single slot 76 | await expect(user.minter.mint(user.address, data)) 77 | .to.emit(minter, "Transfer") 78 | .withArgs(ethers.constants.AddressZero, user.address, 1) 79 | .to.emit(minter, "Transfer") 80 | .withArgs(ethers.constants.AddressZero, user.address, 2) 81 | .to.emit(minter, "SlotChanged") 82 | .withArgs(1, 0, claimID) 83 | .to.emit(minter, "SlotChanged") 84 | .withArgs(2, 0, claimID); 85 | 86 | expect(await minter.ownerOf(1)).to.be.eq(user.address); 87 | expect(await minter.ownerOf(2)).to.be.eq(user.address); 88 | 89 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(2); 90 | 91 | expect(await minter.slotOf(1)).to.be.eq(claimID); 92 | expect(await minter.slotOf(2)).to.be.eq(claimID); 93 | 94 | expect(await minter.tokenInSlotByIndex(claimID, 0)).to.be.eq(1); 95 | expect(await minter.tokenInSlotByIndex(claimID, 1)).to.be.eq(2); 96 | 97 | expect(await minter["balanceOf(uint256)"](1)).to.be.eq("50"); 98 | expect(await minter["balanceOf(uint256)"](1)).to.be.eq("50"); 99 | 100 | await expect(deployer.minter.mint(ethers.constants.AddressZero, data)).to.be.revertedWith("ToZeroAddress"); 101 | }); 102 | 103 | it("an already minted claim (work, impact, creators) cannot be minted again", async function () { 104 | const { user, minter } = await setupTest(); 105 | 106 | const data = await getEncodedImpactClaim(); 107 | 108 | await expect(user.minter.mint(user.address, data)) 109 | .to.emit(minter, "Transfer") 110 | .withArgs(ethers.constants.AddressZero, user.address, 1); 111 | 112 | await expect(user.minter.mint(user.address, data)).to.be.revertedWith("ConflictingClaim"); 113 | 114 | const workScopes = Object.keys(WorkScopes); 115 | const otherData = await getEncodedImpactClaim({ workScopes: [workScopes[1], workScopes[2]] }); 116 | 117 | await expect(user.minter.mint(user.address, otherData)) 118 | .to.emit(minter, "Transfer") 119 | .withArgs(ethers.constants.AddressZero, user.address, 2); 120 | }); 121 | 122 | it("claim can not have overlapping contributors", async function () { 123 | const { user, minter } = await setupTest(); 124 | 125 | const contributors: string[] = []; 126 | Array.from({ length: 3 }).forEach(() => contributors.push(faker.finance.ethereumAddress())); 127 | 128 | const data = await getEncodedImpactClaim({ contributors: contributors }); 129 | 130 | await expect(user.minter.mint(user.address, data)).to.emit(minter, "ImpactClaimed"); 131 | 132 | const overlappingData = await getEncodedImpactClaim({ contributors: [user.address, contributors[0]] }); 133 | 134 | await expect(user.minter.mint(user.address, overlappingData)).to.be.revertedWith("ConflictingClaim"); 135 | }); 136 | 137 | it("allows for dynamic URIs which are consistent for all tokens in a slot which are consistent for all tokens in a slot", async function () { 138 | const { user, minter } = await setupTest(); 139 | 140 | const claim = await newClaim({ uri: "Test 1234", fractions: [50, 50] }); 141 | const shortdata = await getEncodedImpactClaim(claim); 142 | const claimID = 1; 143 | 144 | await expect(user.minter.mint(user.address, shortdata)) 145 | .to.emit(minter, "Transfer") 146 | .withArgs(ethers.constants.AddressZero, user.address, 1) 147 | .to.emit(minter, "Transfer") 148 | .withArgs(ethers.constants.AddressZero, user.address, 2); 149 | 150 | const claimSubbed = subScopeKeysForValues(claim, ImpactScopes); 151 | await validateMetadata(await minter.tokenURI(1), claimSubbed, claim.fractions[0]); 152 | await validateMetadata(await minter.tokenURI(2), claimSubbed, claim.fractions[1]); 153 | await validateMetadata(await minter.slotURI(claimID), claimSubbed); 154 | 155 | const cid = "ipfs://QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/cat.jpg"; 156 | 157 | const claim2 = await newClaim({ ...claim, workTimeframe: [12345678, 87654321], uri: cid, fractions: [50, 50] }); 158 | const claimID2 = 2; 159 | 160 | const data2 = await getEncodedImpactClaim(claim2); 161 | 162 | await expect(user.minter.mint(user.address, data2)) 163 | .to.emit(minter, "Transfer") 164 | .withArgs(ethers.constants.AddressZero, user.address, 3); 165 | 166 | const claim2Subbed = subScopeKeysForValues(claim2, ImpactScopes); 167 | await validateMetadata(await minter.tokenURI(3), claim2Subbed, claim2.fractions[2]); 168 | await validateMetadata(await minter.slotURI(claimID2), claim2Subbed); 169 | }); 170 | 171 | it("parses input data to create hypercert - minimal", async function () { 172 | const { user, minter } = await setupTest(); 173 | 174 | const impactScopes = randomScopes(1); 175 | const workScopes = randomScopes(1); 176 | const options = { 177 | name: "Test minimal", 178 | description: "Light load testing", 179 | rights: Object.keys(Rights), 180 | workTimeframe: [1, 2], 181 | impactTimeframe: [2, 3], 182 | contributors: [user.address], 183 | impactScopes: Object.keys(impactScopes), 184 | workScopes: Object.keys(workScopes), 185 | uri: "ipfs://QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/", 186 | version: 0, 187 | fractions: [100], 188 | }; 189 | 190 | await setupImpactScopes(minter, user.minter, impactScopes); 191 | await setupWorkScopes(minter, user.minter, workScopes); 192 | 193 | const claim = await newClaim(options); 194 | const shortdata = await getEncodedImpactClaim(claim); 195 | const claimID = 1; 196 | 197 | await expect(user.minter.mint(user.address, shortdata)) 198 | .to.emit(minter, "ImpactClaimed") 199 | .withArgs(claimID, user.address, claim.fractions); 200 | 201 | await validateMetadata(await minter.tokenURI(1), subScopeKeysForValues(claim, impactScopes), claim.fractions[0]); 202 | await validateMetadata(await minter.slotURI(claimID), subScopeKeysForValues(claim, impactScopes)); 203 | 204 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(1); 205 | 206 | const hypercert = await minter.getImpactCert(claimID); 207 | 208 | expect(hypercert.exists).to.be.true; 209 | 210 | await compareClaimAgainstInput(hypercert, claim); 211 | }); 212 | 213 | it("parses input data to create hypercert - medium", async function () { 214 | const { user, minter } = await setupTest(); 215 | 216 | const impactScopes = randomScopes(50); 217 | const workScopes = randomScopes(50); 218 | const options = { 219 | name: "Test medium", 220 | description: "Medium load testing", 221 | rights: Object.keys(Rights), 222 | workTimeframe: [1, 2], 223 | impactTimeframe: [2, 3], 224 | contributors: [user.address], 225 | impactScopes: Object.keys(impactScopes), 226 | workScopes: Object.keys(workScopes), 227 | uri: "ipfs://QmW2WQi7j6c7UgJTarActp7tDNikE4B2qXtFCfLPdsgaTQ/", 228 | version: 0, 229 | fractions: new Array(25).fill(50), 230 | }; 231 | 232 | await setupImpactScopes(minter, user.minter, impactScopes); 233 | await setupWorkScopes(minter, user.minter, workScopes); 234 | 235 | const claim = await newClaim(options); 236 | const shortdata = await getEncodedImpactClaim(claim); 237 | const claimID = 1; 238 | 239 | await expect(user.minter.mint(user.address, shortdata)) 240 | .to.emit(minter, "ImpactClaimed") 241 | .withArgs(claimID, user.address, claim.fractions); 242 | 243 | await validateMetadata(await minter.tokenURI(1), subScopeKeysForValues(claim, impactScopes), claim.fractions[0]); 244 | await validateMetadata(await minter.tokenURI(25), subScopeKeysForValues(claim, impactScopes), claim.fractions[24]); 245 | await validateMetadata(await minter.slotURI(claimID), subScopeKeysForValues(claim, impactScopes)); 246 | 247 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(25); 248 | 249 | const hypercert = await minter.getImpactCert(claimID); 250 | 251 | expect(hypercert.exists).to.be.true; 252 | 253 | await compareClaimAgainstInput(hypercert, options); 254 | }); 255 | 256 | it("parses input data to create hypercert - high", async function () { 257 | const { user, minter } = await setupTest(); 258 | 259 | const contributors: string[] = []; 260 | Array.from({ length: 10 }).forEach(() => contributors.push(faker.finance.ethereumAddress())); 261 | 262 | const impactScopes = randomScopes(100); 263 | const workScopes = randomScopes(100); 264 | const options = { 265 | name: "Test high", 266 | description: "High load testing", 267 | rights: Object.keys(Rights), 268 | workTimeframe: [1, 2], 269 | impactTimeframe: [2, 3], 270 | contributors, 271 | impactScopes: Object.keys(impactScopes), 272 | workScopes: Object.keys(workScopes), 273 | uri: "ipfs://test", 274 | version: 0, 275 | fractions: new Array(50).fill(50), 276 | }; 277 | 278 | await setupImpactScopes(minter, user.minter, impactScopes); 279 | await setupWorkScopes(minter, user.minter, workScopes); 280 | 281 | const claim = await newClaim(options); 282 | const shortdata = await getEncodedImpactClaim(claim); 283 | const claimID = 1; 284 | 285 | await expect(user.minter.mint(user.address, shortdata)).to.emit(minter, "ImpactClaimed"); 286 | 287 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(50); 288 | 289 | const claimSubbed = subScopeKeysForValues(claim, impactScopes); 290 | await validateMetadata(await minter.tokenURI(1), claimSubbed, claim.fractions[0]); 291 | await validateMetadata(await minter.tokenURI(50), claimSubbed, claim.fractions[49]); 292 | await validateMetadata(await minter.slotURI(claimID), claimSubbed); 293 | 294 | const hypercert = await minter.getImpactCert(claimID); 295 | 296 | expect(hypercert.exists).to.be.true; 297 | 298 | await compareClaimAgainstInput(hypercert, options); 299 | }); 300 | 301 | it("parses input data to create hypercert - approach limit", async function () { 302 | // GAS COST 28_285_127 303 | const { user, minter } = await setupTest(); 304 | 305 | const contributors: string[] = []; 306 | Array.from({ length: 20 }).forEach(() => contributors.push(faker.finance.ethereumAddress())); 307 | 308 | const n = 75; 309 | const impactScopes = randomScopes(n); 310 | const workScopes = randomScopes(n); 311 | const options = { 312 | name: "Test limit", 313 | description: "Limit load testing", 314 | rights: Object.keys(Rights), 315 | workTimeframe: [1, 2], 316 | impactTimeframe: [2, 3], 317 | contributors, 318 | impactScopes: Object.keys(impactScopes), 319 | workScopes: Object.keys(workScopes), 320 | uri: "ipfs://test", 321 | version: 0, 322 | fractions: new Array(n).fill(50), 323 | }; 324 | 325 | await setupImpactScopes(minter, user.minter, impactScopes); 326 | await setupWorkScopes(minter, user.minter, workScopes); 327 | 328 | const claim = await newClaim(options); 329 | const shortdata = await getEncodedImpactClaim(claim); 330 | const claimID = 1; 331 | 332 | await expect(user.minter.mint(user.address, shortdata)).to.emit(minter, "ImpactClaimed"); 333 | 334 | expect(await minter.tokenSupplyInSlot(claimID)).to.be.eq(n); 335 | 336 | const claimSubbed = subScopeKeysForValues(claim, impactScopes); 337 | await validateMetadata(await minter.tokenURI(1), claimSubbed, claim.fractions[0]); 338 | await validateMetadata(await minter.tokenURI(n), claimSubbed, claim.fractions[n - 1]); 339 | await validateMetadata(await minter.slotURI(claimID), claimSubbed); 340 | 341 | const hypercert = await minter.getImpactCert(claimID); 342 | 343 | expect(hypercert.exists).to.be.true; 344 | 345 | await compareClaimAgainstInput(hypercert, options); 346 | }); 347 | } 348 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.rights.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import setupTest, { setupRights } from "../setup"; 4 | import { Rights } from "../wellKnown"; 5 | 6 | export function shouldBehaveLikeHypercertMinterAddingRights(): void { 7 | it("should allow anyone to add new right", async function () { 8 | const { anon, minter } = await setupTest({ rights: {} }); 9 | await setupRights(anon.minter, minter); 10 | 11 | expect(await minter.rights(Object.keys(Rights)[0])).to.be.eq("admin"); 12 | expect(await minter.rights(Object.keys(Rights)[1])).to.be.eq("mint"); 13 | expect(await minter.rights(Object.keys(Rights)[2])).to.be.eq("merge"); 14 | expect(await minter.rights(Object.keys(Rights)[3])).to.be.eq("split"); 15 | expect(await minter.rights(Object.keys(Rights)[4])).to.be.eq("burn"); 16 | }); 17 | 18 | it("should reject duplicate right", async function () { 19 | const { deployer, minter } = await setupTest({ rights: {} }); 20 | await setupRights(deployer.minter, minter); 21 | 22 | for (const text of Object.values(Rights)) { 23 | await expect(deployer.minter.addRight(text)).to.be.revertedWith("DuplicateScope"); 24 | } 25 | }); 26 | 27 | it("should reject empty right", async function () { 28 | const { user } = await setupTest(); 29 | 30 | await expect(user.minter.addRight("")).to.be.revertedWith("EmptyInput"); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.scopes.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import setupTest, { setupImpactScopes, setupWorkScopes } from "../setup"; 4 | import { ImpactScopes, WorkScopes } from "../wellKnown"; 5 | 6 | export function shouldBehaveLikeHyperCertMinterAddingImpactScopes(): void { 7 | it("should allow anyone to add new impact scopes", async function () { 8 | const { anon, minter } = await setupTest({ impactScopes: {} }); 9 | await setupImpactScopes(anon.minter, minter); 10 | 11 | expect(await minter.impactScopes(Object.keys(ImpactScopes)[0])).to.be.eq("clean-air"); 12 | expect(await minter.impactScopes(Object.keys(ImpactScopes)[1])).to.be.eq("biodiversity"); 13 | expect(await minter.impactScopes(Object.keys(ImpactScopes)[2])).to.be.eq("pollution-reduction"); 14 | expect(await minter.impactScopes(Object.keys(ImpactScopes)[3])).to.be.eq("top-soil-growth"); 15 | }); 16 | 17 | it("should reject duplicate impact scopes", async function () { 18 | const { deployer, minter } = await setupTest({ impactScopes: {} }); 19 | await setupImpactScopes(deployer.minter, minter); 20 | 21 | for (const text of Object.values(ImpactScopes)) { 22 | await expect(deployer.minter.addImpactScope(text)).to.be.revertedWith("DuplicateScope"); 23 | } 24 | }); 25 | 26 | it("should reject empty impact scopes", async function () { 27 | const { user } = await setupTest(); 28 | 29 | await expect(user.minter.addImpactScope("")).to.be.revertedWith("EmptyInput"); 30 | }); 31 | } 32 | 33 | export function shouldBehaveLikeHyperCertMinterAddingWorkScopes(): void { 34 | it("should allow anyone to add new work scopes", async function () { 35 | const { anon, minter } = await setupTest({ workScopes: {} }); 36 | await setupWorkScopes(anon.minter, minter); 37 | 38 | expect(await minter.workScopes(Object.keys(WorkScopes)[0])).to.be.eq("clean-air-tech"); 39 | expect(await minter.workScopes(Object.keys(WorkScopes)[1])).to.be.eq("education"); 40 | expect(await minter.workScopes(Object.keys(WorkScopes)[2])).to.be.eq("tree-planting"); 41 | expect(await minter.workScopes(Object.keys(WorkScopes)[3])).to.be.eq("waterway-cleaning"); 42 | }); 43 | 44 | it("should reject duplicate work scopes", async function () { 45 | const { deployer, minter } = await setupTest({ workScopes: {} }); 46 | await setupWorkScopes(deployer.minter, minter); 47 | 48 | for (const text of Object.values(WorkScopes)) { 49 | await expect(deployer.minter.addWorkScope(text)).to.be.revertedWith("DuplicateScope"); 50 | } 51 | }); 52 | 53 | it("should reject empty work scopes", async function () { 54 | const { user } = await setupTest(); 55 | 56 | await expect(user.minter.addWorkScope("")).to.be.revertedWith("EmptyInput"); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.split.merge.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers } from "hardhat"; 3 | 4 | import setupTest from "../setup"; 5 | import { encodeClaim, newClaim, subScopeKeysForValues, validateMetadata } from "../utils"; 6 | import { ImpactScopes } from "../wellKnown"; 7 | 8 | export function shouldBehaveLikeHypercertMinterSplitAndMerge(): void { 9 | it("should allow fraction owner to split a cert into new fractions - 1-to-many", async function () { 10 | const { user, minter } = await setupTest(); 11 | const claim = await newClaim(); 12 | const data = encodeClaim(claim); 13 | const slot = 1; 14 | 15 | await minter.mint(user.address, data); 16 | 17 | await expect(user.minter.split(1, [50])).to.be.revertedWith("AlreadyMinted(1)"); 18 | await expect(user.minter.split(1, [100, 50])).to.be.revertedWith("InvalidInput()"); 19 | await expect(user.minter.split(1, [20, 50])).to.be.revertedWith("InvalidInput()"); 20 | const fractions4 = [50, 30, 10, 5, 5]; 21 | await expect(user.minter.split(2, fractions4)).to.be.revertedWith("NonExistentToken(2)"); 22 | 23 | await expect(user.minter.split(1, fractions4)) 24 | .to.emit(minter, "Transfer") 25 | .withArgs(ethers.constants.AddressZero, user.address, 2) 26 | .to.emit(minter, "SlotChanged") 27 | .withArgs(2, 0, slot) 28 | .to.emit(minter, "Transfer") 29 | .withArgs(ethers.constants.AddressZero, user.address, 3) 30 | .to.emit(minter, "SlotChanged") 31 | .withArgs(3, 0, slot) 32 | .to.emit(minter, "Transfer") 33 | .withArgs(ethers.constants.AddressZero, user.address, 4) 34 | .to.emit(minter, "SlotChanged") 35 | .withArgs(4, 0, slot) 36 | .to.emit(minter, "Transfer") 37 | .withArgs(ethers.constants.AddressZero, user.address, 5) 38 | .to.emit(minter, "SlotChanged") 39 | .withArgs(5, 0, slot); 40 | 41 | const claimSubbed = subScopeKeysForValues(claim, ImpactScopes); 42 | for (let i = 1; i <= fractions4.length; i++) { 43 | expect(await minter.ownerOf(i)).to.be.eq(user.address); 44 | expect(await minter.slotOf(i)).to.be.eq(slot); 45 | const units = fractions4[i - 1]; 46 | expect(await minter["balanceOf(uint256)"](i)).to.be.eq(units); 47 | await validateMetadata(await minter.tokenURI(i), claimSubbed, units); 48 | } 49 | 50 | //TODO tokenSupply 51 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(5); 52 | }); 53 | 54 | it("should allow fraction owner to merge a cert fraction into an existing fraction", async function () { 55 | const { user, minter } = await setupTest(); 56 | const claim = await newClaim({ fractions: [20, 30, 50] }); 57 | const data = encodeClaim(claim); 58 | const slot = 1; 59 | 60 | await minter.mint(user.address, data); 61 | 62 | expect(await minter["balanceOf(uint256)"](1)).to.be.eq("20"); 63 | expect(await minter["balanceOf(uint256)"](2)).to.be.eq("30"); 64 | expect(await minter["balanceOf(uint256)"](3)).to.be.eq("50"); 65 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(3); 66 | 67 | await expect(user.minter.merge([1, 2])) 68 | .to.emit(minter, "TransferValue") 69 | .withArgs(1, 2, 20); 70 | 71 | await expect(minter["balanceOf(uint256)"](1)).to.be.revertedWith("NonExistentToken"); 72 | expect(await minter["balanceOf(uint256)"](2)).to.be.eq("50"); 73 | expect(await minter["balanceOf(uint256)"](3)).to.be.eq("50"); 74 | expect(await minter.tokenSupplyInSlot(slot)).to.be.eq(2); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers, getNamedAccounts } from "hardhat"; 3 | 4 | import { HyperCertMinter as Minter } from "../../src/types"; 5 | import { HyperCertMinter } from "../wellKnown"; 6 | import { shouldBehaveLikeHypercertMinterBurning as shouldBehaveLikeHyperCertMinterBurning } from "./HyperCertMinter.burning"; 7 | import { shouldBehaveLikeHypercertMinterIntegration } from "./HyperCertMinter.integration"; 8 | import { shouldBehaveLikeHypercertMinterMinting as shouldBehaveLikeHyperCertMinterMinting } from "./HyperCertMinter.minting"; 9 | import { shouldBehaveLikeHypercertMinterAddingRights as shouldBehaveLikeHyperCertMinterAddingRights } from "./HyperCertMinter.rights"; 10 | import { 11 | shouldBehaveLikeHyperCertMinterAddingImpactScopes, 12 | shouldBehaveLikeHyperCertMinterAddingWorkScopes, 13 | } from "./HyperCertMinter.scopes"; 14 | import { shouldBehaveLikeHypercertMinterSplitAndMerge as shouldBehaveLikeHyperCertMinterSplitAndMerge } from "./HyperCertMinter.split.merge"; 15 | import { shouldBehaveLikeHypercertMinterUpgrade as shouldBehaveLikeHyperCertMinterUpgrade } from "./HyperCertMinter.upgrade"; 16 | 17 | describe("Unit tests", function () { 18 | describe("HyperCert Minter", function () { 19 | it("is an initializable ERC3525 contract", async () => { 20 | const tokenFactory = await ethers.getContractFactory(HyperCertMinter); 21 | const tokenInstance = await tokenFactory.deploy(); 22 | const { anon } = await getNamedAccounts(); 23 | 24 | // 0xd5358140 is the ERC165 interface identifier for EIP3525 25 | expect(await tokenInstance.supportsInterface("0xd5358140")).to.be.true; 26 | expect(await tokenInstance.name()).to.be.eq("HyperCerts"); 27 | expect(await tokenInstance.symbol()).to.be.eq("HCRT"); 28 | expect(await tokenInstance.valueDecimals()).to.be.eq(0); 29 | 30 | await expect(tokenInstance.initialize(anon)).to.be.revertedWith("Initializable: contract is already initialized"); 31 | }); 32 | 33 | shouldBehaveLikeHyperCertMinterMinting(); 34 | shouldBehaveLikeHyperCertMinterUpgrade(); 35 | shouldBehaveLikeHyperCertMinterBurning(); 36 | shouldBehaveLikeHyperCertMinterAddingImpactScopes(); 37 | shouldBehaveLikeHyperCertMinterAddingWorkScopes(); 38 | shouldBehaveLikeHyperCertMinterAddingRights(); 39 | shouldBehaveLikeHyperCertMinterSplitAndMerge(); 40 | shouldBehaveLikeHypercertMinterIntegration(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/hypercert_minter/HyperCertMinter.upgrade.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { ethers, getNamedAccounts, upgrades } from "hardhat"; 3 | 4 | import { HyperCertMinterUpgrade } from "../../src/types"; 5 | import setupTest, { setupImpactScopes, setupRights, setupTestMetadata, setupWorkScopes } from "../setup"; 6 | import { getEncodedImpactClaim, newClaim, subScopeKeysForValues, validateMetadata } from "../utils"; 7 | import { HyperCertMinter, HyperCertMinter_Upgrade, ImpactScopes, UPGRADER_ROLE } from "../wellKnown"; 8 | 9 | export function shouldBehaveLikeHypercertMinterUpgrade(): void { 10 | it("supports upgrader role", async function () { 11 | it("Support admin role", async function () { 12 | const { user, deployer, minter } = await setupTest(); 13 | 14 | expect(await minter.hasRole(UPGRADER_ROLE, deployer.address)).to.be.true; 15 | expect(await minter.hasRole(UPGRADER_ROLE, user.address)).to.be.false; 16 | 17 | await expect(deployer.minter.grantRole(UPGRADER_ROLE, user.address)).to.be.revertedWith( 18 | `AccessControl: account ${deployer.address.toLowerCase()} is missing role ${UPGRADER_ROLE}`, 19 | ); 20 | 21 | await expect(deployer.minter.grantRole(UPGRADER_ROLE, user.address)) 22 | .to.emit(minter, "RoleGranted") 23 | .withArgs(UPGRADER_ROLE, user.address, deployer.address); 24 | }); 25 | }); 26 | 27 | it("updates version number on update", async function () { 28 | const HypercertMinterV0Factory = await ethers.getContractFactory(HyperCertMinter); 29 | const { anon } = await getNamedAccounts(); 30 | 31 | const UpgradeFactory = await ethers.getContractFactory(HyperCertMinter_Upgrade); 32 | 33 | const proxy = await upgrades.deployProxy(HypercertMinterV0Factory, [anon], { 34 | kind: "uups", 35 | }); 36 | 37 | expect(await proxy.version()).to.be.eq(0); 38 | 39 | const upgrade = await upgrades.upgradeProxy(proxy, UpgradeFactory, { 40 | call: "updateVersion", 41 | }); 42 | 43 | expect(await proxy.version()).to.be.eq(1); 44 | expect(await upgrade.version()).to.be.eq(1); 45 | }); 46 | 47 | it("retains state of minted tokens", async function () { 48 | const { user } = await getNamedAccounts(); 49 | const claim = await newClaim(); 50 | const data = await getEncodedImpactClaim(claim); 51 | const { sft } = await setupTestMetadata(); 52 | 53 | const HypercertMinterFactory = await ethers.getContractFactory(HyperCertMinter); 54 | const UpgradeFactory = await ethers.getContractFactory(HyperCertMinter_Upgrade); 55 | 56 | const proxy = await upgrades.deployProxy(HypercertMinterFactory, [sft.address], { 57 | kind: "uups", 58 | }); 59 | expect(await proxy.version()).to.be.eq(0); 60 | 61 | const proxyWithUser = await ethers.getContractAt(HyperCertMinter, proxy.address, user); 62 | await setupImpactScopes(proxyWithUser); 63 | await setupRights(proxyWithUser); 64 | await setupWorkScopes(proxyWithUser); 65 | await proxyWithUser.mint(user, data); 66 | const claimSubbed = subScopeKeysForValues(claim, ImpactScopes); 67 | await validateMetadata(await proxyWithUser.tokenURI(1), claimSubbed, claim.fractions[0]); 68 | await validateMetadata(await proxyWithUser.slotURI(1), claimSubbed); 69 | 70 | const upgrade = await upgrades.upgradeProxy(proxy, UpgradeFactory, { 71 | call: "updateVersion", 72 | }); 73 | 74 | expect(await upgrade.mockedUpgradeFunction()).to.be.true; 75 | 76 | await validateMetadata(await upgrade.tokenURI(1), claimSubbed, claim.fractions[0]); 77 | await validateMetadata(await upgrade.slotURI(1), claimSubbed); 78 | 79 | const upgradeWithUser = await ethers.getContractAt(HyperCertMinter_Upgrade, upgrade.address, user); 80 | await expect(upgradeWithUser.split(1, [50, 50])) 81 | .to.emit(upgradeWithUser, "Transfer") 82 | .withArgs(ethers.constants.AddressZero, user, 2) 83 | .to.emit(upgradeWithUser, "SlotChanged") 84 | .withArgs(2, 0, 1); 85 | }); 86 | } 87 | -------------------------------------------------------------------------------- /test/hypercert_svg/HypercertSVG.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { promises as fs } from "fs"; 3 | import { ethers } from "hardhat"; 4 | 5 | import { HyperCertSVG as SVG } from "../../src/types"; 6 | import { setupTestSVG } from "../setup"; 7 | import { randomScopes, validateSVG } from "../utils"; 8 | import { DEFAULT_ADMIN_ROLE, HyperCertSVG, SVGBackgrounds, UPGRADER_ROLE } from "../wellKnown"; 9 | 10 | type SVGInput = { 11 | name: string; 12 | impactScopes: string[]; 13 | workTimeframe: [number, number]; 14 | impactTimeframe: [number, number]; 15 | units: number; 16 | totalUnits: number; 17 | }; 18 | 19 | const input1: SVGInput = { 20 | name: "TestSVG_S", 21 | impactScopes: ["Developing SVG rendering"], 22 | workTimeframe: [1640998800, 1643590800], 23 | impactTimeframe: [1643677200, 1646010000], 24 | units: 333, 25 | totalUnits: 1000, 26 | }; 27 | 28 | const input2: SVGInput = { 29 | name: "TestSVG2_M", 30 | impactScopes: ["Developing further SVG rendering", "tralalalala"], 31 | workTimeframe: [1640998800, 1643590800], 32 | impactTimeframe: [1643677200, 1646010000], 33 | units: 500, 34 | totalUnits: 1000, 35 | }; 36 | 37 | const input3: SVGInput = { 38 | name: "TestSVG_L", 39 | impactScopes: Object.values(randomScopes(100)), 40 | workTimeframe: [1640998800, 1643590800], 41 | impactTimeframe: [1643677200, 1646010000], 42 | units: 500, 43 | totalUnits: 1000, 44 | }; 45 | 46 | const input4: SVGInput = { 47 | name: "TestSVG_XL: extraordinarily capacious", 48 | impactScopes: Object.values(randomScopes(200)), 49 | workTimeframe: [1640998800, 1643590800], 50 | impactTimeframe: [1643677200, 1646010000], 51 | units: 500, 52 | totalUnits: 1000, 53 | }; 54 | 55 | const input5: SVGInput = { 56 | name: "TestSVG_XL: OneTwoThreeFourFiveSixSevenEightNine", 57 | impactScopes: Object.values(randomScopes(200)), 58 | workTimeframe: [1640998800, 1643590800], 59 | impactTimeframe: [1643677200, 1646010000], 60 | units: 500, 61 | totalUnits: 1000, 62 | }; 63 | 64 | const input6: SVGInput = { 65 | name: "Supercalifragilisticexpialidocious", 66 | impactScopes: Object.values(randomScopes(200)), 67 | workTimeframe: [1640998800, 1643590800], 68 | impactTimeframe: [1643677200, 1646010000], 69 | units: 500, 70 | totalUnits: 1000, 71 | }; 72 | 73 | const BASE_PATH = "test/hypercert_svg/"; 74 | 75 | const generateAndValidateSVG = async ( 76 | name: string, 77 | input: SVGInput, 78 | fn: (tokenInstance: SVG) => Promise, 79 | fraction: boolean = false, 80 | ) => { 81 | const tokenFactory = await ethers.getContractFactory(HyperCertSVG); 82 | const tokenInstance = await tokenFactory.deploy(); 83 | 84 | //Primary, labels, background 85 | await tokenInstance.addColors(["#F3556F", "#121933", "#D4BFFF"]); // 86 | await tokenInstance.addColors(["#FFBFCA", "#FFFFFF", "#5500FF"]); // 87 | await tokenInstance.addColors(["#25316D", "#121933", "#80E5D3"]); // 88 | await tokenInstance.addColors(["#25316D", "#FFFFFF", "#F3556F"]); // 89 | await tokenInstance.addColors(["#80E5D3", "#FFFFFF", "#121933"]); // 90 | await tokenInstance.addColors(["#FEF5AC", "#FFFFFF", "#25316D"]); // 91 | await tokenInstance.addColors(["#F3556F", "#121933", "#FFBFCA"]); // 92 | await tokenInstance.addColors(["#5500FF", "#121933", "#FFCC00"]); // 93 | 94 | await tokenInstance.addBackground(SVGBackgrounds[0]); 95 | await tokenInstance.addBackground(SVGBackgrounds[1]); 96 | await tokenInstance.addBackground(SVGBackgrounds[2]); 97 | await tokenInstance.addBackground(SVGBackgrounds[3]); 98 | await tokenInstance.addBackground(SVGBackgrounds[4]); 99 | await tokenInstance.addBackground(SVGBackgrounds[5]); 100 | await tokenInstance.addBackground(SVGBackgrounds[6]); 101 | await tokenInstance.addBackground(SVGBackgrounds[7]); 102 | 103 | const svg = await fn(tokenInstance); 104 | await fs.writeFile(`${BASE_PATH}test_${name}.svg`, svg); 105 | await validateSVG(svg, input, fraction); 106 | }; 107 | 108 | describe("Unit tests", function () { 109 | describe(HyperCertSVG, async function () { 110 | it("is an initializable contract", async () => { 111 | const tokenFactory = await ethers.getContractFactory(HyperCertSVG); 112 | const tokenInstance = await tokenFactory.deploy(); 113 | 114 | await expect(tokenInstance.initialize()).to.be.revertedWith("Initializable: contract is already initialized"); 115 | }); 116 | 117 | it("is a UUPS-upgradeable contract", async () => { 118 | const { sft } = await setupTestSVG(); 119 | 120 | await expect(sft.proxiableUUID()).to.be.revertedWith("UUPSUpgradeable: must not be called through delegatecall"); 121 | }); 122 | 123 | const roles = <[string, string][]>[ 124 | ["admin", DEFAULT_ADMIN_ROLE], 125 | ["upgrader", UPGRADER_ROLE], 126 | ]; 127 | 128 | roles.forEach(([name, role]) => { 129 | it(`supports ${name} role`, async function () { 130 | const { sft, user, deployer } = await setupTestSVG(); 131 | 132 | expect(await sft.hasRole(role, deployer.address)).to.be.true; 133 | expect(await sft.hasRole(role, user.address)).to.be.false; 134 | 135 | await expect(user.sft.grantRole(role, user.address)).to.be.revertedWith( 136 | `AccessControl: account ${user.address.toLowerCase()} is missing role ${DEFAULT_ADMIN_ROLE}`, 137 | ); 138 | 139 | await expect(deployer.sft.grantRole(role, user.address)) 140 | .to.emit(sft, "RoleGranted") 141 | .withArgs(role, user.address, deployer.address); 142 | }); 143 | }); 144 | 145 | const data = [input1, input2, input3, input4, input5, input6]; 146 | 147 | data.forEach(input => { 148 | it(`should generate valid hypercert SVG (${input.name})`, async () => { 149 | await generateAndValidateSVG(`hypercert_${input.name.toLowerCase()}`, input, tokenInstance => 150 | tokenInstance.generateSvgHyperCert( 151 | input.name, 152 | input.impactScopes, 153 | input.workTimeframe, 154 | input.impactTimeframe, 155 | input.totalUnits, 156 | ), 157 | ); 158 | }); 159 | 160 | it(`should generate valid token SVG (${input.name})`, async () => { 161 | await generateAndValidateSVG( 162 | `fraction_${input.name.toLowerCase()}`, 163 | input, 164 | tokenInstance => 165 | tokenInstance.generateSvgFraction( 166 | input.name, 167 | input.impactScopes, 168 | input.workTimeframe, 169 | input.impactTimeframe, 170 | input.units, 171 | input.totalUnits, 172 | ), 173 | true, 174 | ); 175 | }); 176 | }); 177 | }); 178 | }); 179 | -------------------------------------------------------------------------------- /test/hypercert_svg/HypercertSVG.ts~9f761327f106ce841b6aa7856e89e0a7557b823a: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { format } from "date-fns"; 3 | import { promises as fs } from "fs"; 4 | import { ethers } from "hardhat"; 5 | import { parseXml } from "libxmljs"; 6 | 7 | import { HyperCertSVG as SVG } from "../../src/types"; 8 | import { SVGBackgrounds } from "../../src/util/wellKnown"; 9 | import { setupTestSVG } from "../setup"; 10 | import { randomScopes } from "../utils"; 11 | import { DEFAULT_ADMIN_ROLE, HyperCertSVG, UPGRADER_ROLE } from "../wellKnown"; 12 | 13 | type InputType = { 14 | name: string; 15 | scopesOfImpact: string[]; 16 | workTimeframe: [number, number]; 17 | impactTimeframe: [number, number]; 18 | units: number; 19 | totalUnits: number; 20 | }; 21 | 22 | const input1: InputType = { 23 | name: "TestSVG_S", 24 | scopesOfImpact: ["Developing SVG rendering"], 25 | workTimeframe: [1640998800, 1643590800], 26 | impactTimeframe: [1643677200, 1646010000], 27 | units: 333, 28 | totalUnits: 1000, 29 | }; 30 | 31 | const input2: InputType = { 32 | name: "TestSVG2_M", 33 | scopesOfImpact: ["Developing further SVG rendering", "tralalalala"], 34 | workTimeframe: [1640998800, 1643590800], 35 | impactTimeframe: [1643677200, 1646010000], 36 | units: 500, 37 | totalUnits: 1000, 38 | }; 39 | 40 | const input3: InputType = { 41 | name: "TestSVG_L", 42 | scopesOfImpact: Object.values(randomScopes(100)), 43 | workTimeframe: [1640998800, 1643590800], 44 | impactTimeframe: [1643677200, 1646010000], 45 | units: 500, 46 | totalUnits: 1000, 47 | }; 48 | 49 | const input4: InputType = { 50 | name: "TestSVG_XL_____________________", 51 | scopesOfImpact: Object.keys(randomScopes(200)), 52 | workTimeframe: [1640998800, 1643590800], 53 | impactTimeframe: [1643677200, 1646010000], 54 | units: 500, 55 | totalUnits: 1000, 56 | }; 57 | 58 | const BASE_PATH = "test/hypercert_svg/"; 59 | 60 | const formatDate = (unix: number) => format(new Date(unix * 1000), "yyyy-M-d"); 61 | const formatTimeframe = (timeframe: [number, number]) => `${formatDate(timeframe[0])} > ${formatDate(timeframe[1])}`; 62 | const formatFraction = (input: InputType) => { 63 | const percentage = ((input.units / input.totalUnits) * 100).toLocaleString("en-us", { 64 | minimumFractionDigits: 2, 65 | }); 66 | return `${percentage} %`; 67 | }; 68 | const truncate = (scope: string, maxLength: number = 30) => 69 | scope.length <= maxLength ? scope : `${scope.substring(0, maxLength - 3)}...`; 70 | 71 | const generateAndValidateSVG = async ( 72 | name: string, 73 | input: InputType, 74 | fn: (tokenInstance: SVG) => Promise, 75 | fraction: boolean = false, 76 | ) => { 77 | const tokenFactory = await ethers.getContractFactory(HyperCertSVG); 78 | const tokenInstance = await tokenFactory.deploy(); 79 | await tokenInstance.addBackground(SVGBackgrounds[0]); 80 | await tokenInstance.addBackground(SVGBackgrounds[1]); 81 | await tokenInstance.addBackground(SVGBackgrounds[2]); 82 | await tokenInstance.addBackground(SVGBackgrounds[3]); 83 | await tokenInstance.addBackground(SVGBackgrounds[4]); 84 | await tokenInstance.addBackground(SVGBackgrounds[5]); 85 | await tokenInstance.addBackground(SVGBackgrounds[6]); 86 | await tokenInstance.addBackground(SVGBackgrounds[7]); 87 | 88 | const svg = await fn(tokenInstance); 89 | await fs.writeFile(`${BASE_PATH}test_${name}.svg`, svg); 90 | await validate(svg, input, fraction); 91 | }; 92 | 93 | const validate = async (svg: string, input: InputType, fraction: boolean = false) => { 94 | const baseUrl = `${BASE_PATH}xsd/`; 95 | const xsd = await fs.readFile(`${baseUrl}svg.xsd`, { encoding: "utf-8" }); 96 | const xsdDoc = parseXml(xsd, { baseUrl }); 97 | const svgDoc = parseXml(svg); 98 | svgDoc.validate(xsdDoc); 99 | 100 | const truncName = truncate(input.name); 101 | expect(svgDoc.find(`//*[@id='name-color']//*[text()='${truncName}']`).length).to.eq( 102 | 1, 103 | `Name "${truncName}" not found`, 104 | ); 105 | input.scopesOfImpact.slice(0, 2).forEach(scope => { 106 | const truncScope = truncate(scope); 107 | expect(svgDoc.find(`//*[@id='description-color']//*[text()='${truncScope}']`).length).to.eq( 108 | 1, 109 | `Scope "${truncScope}" not found`, 110 | ); 111 | }); 112 | expect( 113 | svgDoc.find(`//*[@id='work-period-color']//*[text()='Work Period: ${formatTimeframe(input.workTimeframe)}']`) 114 | .length, 115 | ).to.eq(1, "Work period not found"); 116 | expect( 117 | svgDoc.find(`//*[@id='impact-period-color']//*[text()='Impact Period: ${formatTimeframe(input.impactTimeframe)}']`) 118 | .length, 119 | ).to.eq(1, "Impact period not found"); 120 | if (fraction) { 121 | expect(svgDoc.find(`//*[@id='fraction-color']//*[text()='${formatFraction(input)}']`).length).to.eq( 122 | 1, 123 | "Fraction not found", 124 | ); 125 | } 126 | 127 | expect(svgDoc.validationErrors.length).to.eq(0, svgDoc.validationErrors.join("\n")); 128 | }; 129 | 130 | describe("Unit tests", function () { 131 | describe(HyperCertSVG, async function () { 132 | it("is an initializable contract", async () => { 133 | const tokenFactory = await ethers.getContractFactory(HyperCertSVG); 134 | const tokenInstance = await tokenFactory.deploy(); 135 | 136 | await expect(tokenInstance.initialize()).to.be.revertedWith("Initializable: contract is already initialized"); 137 | }); 138 | 139 | it("is a UUPS-upgradeable contract", async () => { 140 | const { sft } = await setupTestSVG(); 141 | 142 | await expect(sft.proxiableUUID()).to.be.revertedWith("UUPSUpgradeable: must not be called through delegatecall"); 143 | }); 144 | 145 | const roles = <[string, string][]>[ 146 | ["admin", DEFAULT_ADMIN_ROLE], 147 | ["upgrader", UPGRADER_ROLE], 148 | ]; 149 | 150 | roles.forEach(([name, role]) => { 151 | it(`supports ${name} role`, async function () { 152 | const { sft, user, deployer } = await setupTestSVG(); 153 | 154 | expect(await sft.hasRole(role, deployer.address)).to.be.true; 155 | expect(await sft.hasRole(role, user.address)).to.be.false; 156 | 157 | await expect(user.sft.grantRole(role, user.address)).to.be.revertedWith( 158 | `AccessControl: account ${user.address.toLowerCase()} is missing role ${DEFAULT_ADMIN_ROLE}`, 159 | ); 160 | 161 | await expect(deployer.sft.grantRole(role, user.address)) 162 | .to.emit(sft, "RoleGranted") 163 | .withArgs(role, user.address, deployer.address); 164 | }); 165 | }); 166 | 167 | const data = [input1, input2, input3, input4]; 168 | 169 | data.forEach(input => { 170 | it(`should generate valid hypercert SVG (${input.name})`, async () => { 171 | await generateAndValidateSVG(`hypercert_${input.name.toLowerCase()}`, input, tokenInstance => 172 | tokenInstance.generateSvgHyperCert( 173 | input.name, 174 | input.scopesOfImpact, 175 | input.workTimeframe, 176 | input.impactTimeframe, 177 | input.totalUnits, 178 | ), 179 | ); 180 | }); 181 | 182 | it(`should generate valid token SVG (${input.name})`, async () => { 183 | await generateAndValidateSVG( 184 | `fraction_${input.name.toLowerCase()}`, 185 | input, 186 | tokenInstance => 187 | tokenInstance.generateSvgFraction( 188 | input.name, 189 | input.scopesOfImpact, 190 | input.workTimeframe, 191 | input.impactTimeframe, 192 | input.units, 193 | input.totalUnits, 194 | ), 195 | true, 196 | ); 197 | }); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { deployments } from "hardhat"; 3 | 4 | import { 5 | ERC3525_Testing, 6 | HyperCertMinter, 7 | HyperCertMinterUpgrade, 8 | HyperCertMetadata as Metadata, 9 | HyperCertSVG as SVG, 10 | } from "../src/types"; 11 | import { 12 | ERC3525, 13 | HyperCertMetadata, 14 | HyperCertMinter_Current, 15 | HyperCertSVG, 16 | ImpactScopes, 17 | Rights, 18 | WorkScopes, 19 | } from "./wellKnown"; 20 | 21 | export type HyperCertContract = HyperCertMinter | HyperCertMinterUpgrade; 22 | export type ERC3525 = ERC3525_Testing; 23 | 24 | export type AddressedHyperCertMinterContract = { 25 | address: string; 26 | minter: HyperCertContract; 27 | }; 28 | 29 | export type HyperCertCollection = { 30 | minter: HyperCertContract; 31 | deployer: AddressedHyperCertMinterContract; 32 | user: AddressedHyperCertMinterContract; 33 | anon: AddressedHyperCertMinterContract; 34 | }; 35 | 36 | export const setupTestERC3525 = deployments.createFixture( 37 | async ({ deployments, getNamedAccounts, ethers }, _options) => { 38 | await deployments.fixture(); // ensure you start from a fresh deployments 39 | const { deployer, user, anon } = await getNamedAccounts(); 40 | 41 | // Contracts 42 | const sft: ERC3525 = await ethers.getContract(ERC3525); 43 | 44 | // Account config 45 | const setupAddress = async (address: string) => { 46 | return { 47 | address: address, 48 | sft: await ethers.getContract(ERC3525, address), 49 | }; 50 | }; 51 | 52 | // Struct 53 | return { 54 | sft, 55 | deployer: await setupAddress(deployer), 56 | user: await setupAddress(user), 57 | anon: await setupAddress(anon), 58 | }; 59 | }, 60 | ); 61 | 62 | export const setupTestMetadata = deployments.createFixture( 63 | async ({ deployments, getNamedAccounts, ethers }, _options) => { 64 | await deployments.fixture(); // ensure you start from a fresh deployments 65 | const { deployer, user, anon } = await getNamedAccounts(); 66 | 67 | // Contracts 68 | const sft = await ethers.getContract(HyperCertMetadata); 69 | 70 | // Account config 71 | const setupAddress = async (address: string) => { 72 | return { 73 | address: address, 74 | sft: await ethers.getContract(HyperCertMetadata, address), 75 | }; 76 | }; 77 | 78 | // Struct 79 | return { 80 | sft, 81 | deployer: await setupAddress(deployer), 82 | user: await setupAddress(user), 83 | anon: await setupAddress(anon), 84 | }; 85 | }, 86 | ); 87 | 88 | export const setupTestSVG = deployments.createFixture(async ({ deployments, getNamedAccounts, ethers }, _options) => { 89 | await deployments.fixture(); // ensure you start from a fresh deployments 90 | const { deployer, user, anon } = await getNamedAccounts(); 91 | 92 | // Contracts 93 | const sft = await ethers.getContract(HyperCertSVG); 94 | 95 | // Account config 96 | const setupAddress = async (address: string) => { 97 | return { 98 | address: address, 99 | sft: await ethers.getContract(HyperCertSVG, address), 100 | }; 101 | }; 102 | 103 | // Struct 104 | return { 105 | sft, 106 | deployer: await setupAddress(deployer), 107 | user: await setupAddress(user), 108 | anon: await setupAddress(anon), 109 | }; 110 | }); 111 | 112 | const setupTest = deployments.createFixture< 113 | HyperCertCollection, 114 | { 115 | impactScopes?: { 116 | [k: string]: string; 117 | }; 118 | rights?: { 119 | [k: string]: string; 120 | }; 121 | workScopes?: { 122 | [k: string]: string; 123 | }; 124 | } 125 | >(async ({ deployments, getNamedAccounts, ethers }, options) => { 126 | await deployments.fixture(); // ensure you start from a fresh deployments 127 | const { deployer, user, anon } = await getNamedAccounts(); 128 | 129 | // Contracts 130 | const minter: HyperCertContract = await ethers.getContract(HyperCertMinter_Current); 131 | 132 | // Account config 133 | const setupAddress = async (address: string) => { 134 | return { 135 | address: address, 136 | minter: await ethers.getContract(HyperCertMinter_Current, address), 137 | }; 138 | }; 139 | 140 | await setupImpactScopes(minter, minter, options?.impactScopes); 141 | await setupRights(minter, minter, options?.rights); 142 | await setupWorkScopes(minter, minter, options?.workScopes); 143 | 144 | // Struct 145 | return { 146 | minter, 147 | deployer: await setupAddress(deployer), 148 | user: await setupAddress(user), 149 | anon: await setupAddress(anon), 150 | }; 151 | }); 152 | 153 | export const setupImpactScopes = async ( 154 | contract: HyperCertContract, 155 | contractAtAddress?: HyperCertContract, 156 | impactScopes = ImpactScopes, 157 | ) => { 158 | for (const [hash, text] of Object.entries(impactScopes)) { 159 | await expect((contractAtAddress ?? contract).addImpactScope(text)) 160 | .to.emit(contract, "ImpactScopeAdded") 161 | .withArgs(hash, text); 162 | } 163 | }; 164 | 165 | export const setupRights = async ( 166 | contract: HyperCertContract, 167 | contractAtAddress?: HyperCertContract, 168 | rights = Rights, 169 | ) => { 170 | for (const [hash, text] of Object.entries(rights)) { 171 | await expect((contractAtAddress ?? contract).addRight(text)) 172 | .to.emit(contract, "RightAdded") 173 | .withArgs(hash, text); 174 | } 175 | }; 176 | 177 | export const setupWorkScopes = async ( 178 | contract: HyperCertContract, 179 | contractAtAddress?: HyperCertContract, 180 | workScopes = WorkScopes, 181 | ) => { 182 | for (const [hash, text] of Object.entries(workScopes)) { 183 | await expect((contractAtAddress ?? contract).addWorkScope(text)) 184 | .to.emit(contract, "WorkScopeAdded") 185 | .withArgs(hash, text); 186 | } 187 | }; 188 | 189 | export default setupTest; 190 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { ParamType } from "@ethersproject/abi"; 2 | import { expect } from "chai"; 3 | import { format } from "date-fns"; 4 | import { BigNumber, utils } from "ethers"; 5 | import { promises as fs } from "fs"; 6 | import { ethers, getNamedAccounts } from "hardhat"; 7 | import { parseXml } from "libxmljs"; 8 | 9 | import { HyperCertMinter } from "../src/types"; 10 | import { DataApplicationJson, ImpactScopes, LoremIpsum, Rights, WorkScopes } from "./wellKnown"; 11 | 12 | interface Dictionary { 13 | [key: string]: T; 14 | } 15 | 16 | class Cached { 17 | _result?: T; 18 | _init: () => T; 19 | constructor(initializer: () => T) { 20 | this._init = initializer; 21 | } 22 | value() { 23 | if (!this._result) { 24 | this._result = this._init(); 25 | } 26 | return this._result; 27 | } 28 | } 29 | 30 | export type Claim = { 31 | rights: string[]; 32 | workTimeframe: [number, number]; 33 | impactTimeframe: [number, number]; 34 | contributors: string[]; 35 | workScopes: string[]; 36 | impactScopes: string[]; 37 | name: string; 38 | description: string; 39 | uri: string; 40 | version: number; 41 | fractions: number[]; 42 | }; 43 | 44 | type Metadata = { 45 | name: string; 46 | description: string; 47 | external_url: string; 48 | image: string; 49 | properties: Dictionary; 50 | }; 51 | 52 | type MetadataProperty = { 53 | name: string; 54 | description: string; 55 | value: number | string; 56 | is_intrinsic: boolean; 57 | }; 58 | 59 | export type SVGInput = { 60 | name: string; 61 | impactScopes: string[]; 62 | workTimeframe: [number, number]; 63 | impactTimeframe: [number, number]; 64 | units?: number; 65 | totalUnits: number; 66 | }; 67 | 68 | export const newClaim = async (claim?: Partial) => { 69 | const getNamedAccountsAsArray = async () => { 70 | const { user, anon } = await getNamedAccounts(); 71 | return [user, anon]; 72 | }; 73 | 74 | return { 75 | rights: claim?.rights || Object.keys(Rights), 76 | workTimeframe: claim?.workTimeframe || [123456789, 123456789], 77 | impactTimeframe: claim?.impactTimeframe || [987654321, 987654321], 78 | contributors: claim?.contributors || (await getNamedAccountsAsArray()), 79 | workScopes: claim?.workScopes || Object.keys(WorkScopes), 80 | impactScopes: claim?.impactScopes || Object.keys(ImpactScopes), 81 | name: claim?.name || "Impact Claim 1", 82 | description: claim?.description || "Impact Claim 1 description", 83 | uri: claim?.uri || "ipfs://mockedImpactClaim", 84 | version: claim?.version || 0, 85 | fractions: claim?.fractions || [100], 86 | }; 87 | }; 88 | 89 | export const getEncodedImpactClaim = async (claim?: Partial) => encodeClaim(await newClaim(claim)); 90 | 91 | //TODO input types 92 | export const encodeClaim = (c: Claim) => { 93 | const types = [ 94 | "uint256[]", 95 | "uint256[]", 96 | "uint256[]", 97 | "uint64[2]", 98 | "uint64[2]", 99 | "address[]", 100 | "string", 101 | "string", 102 | "string", 103 | "uint64[]", 104 | ]; 105 | const values = [ 106 | c.rights, 107 | c.workScopes, 108 | c.impactScopes, 109 | c.workTimeframe, 110 | c.impactTimeframe, 111 | c.contributors, 112 | c.name, 113 | c.description, 114 | c.uri, 115 | c.fractions, 116 | ]; 117 | 118 | return encode(types, values); 119 | }; 120 | 121 | export const getClaimHash = async (claim: Claim) => { 122 | const { workTimeframe, workScopes, impactTimeframe, impactScopes } = claim; 123 | const types = ["uint64[2]", "uint256[]", "uint64[2]", "uint256[]"]; 124 | const values = [workTimeframe, workScopes, impactTimeframe, impactScopes]; 125 | 126 | return hash256(types, values); 127 | }; 128 | 129 | export const getClaimSlotID = async (claim: Claim) => { 130 | return BigNumber.from(await getClaimHash(claim)); 131 | }; 132 | 133 | export const encode = ( 134 | types: ReadonlyArray, 135 | values: ReadonlyArray, 136 | ) => new ethers.utils.AbiCoder().encode(types, values); 137 | 138 | export const hash256 = ( 139 | types: ReadonlyArray, 140 | values: ReadonlyArray, 141 | ) => ethers.utils.keccak256(encode(types, values)); 142 | 143 | export const toHashMap = (array: string[]) => Object.fromEntries(array.map(s => [hash256(["string"], [s]), s])); 144 | 145 | const loremIpsumCache = new Cached(() => LoremIpsum.split(/[\s,.]+/).map(s => s.toLowerCase())); 146 | 147 | const randomWord = () => { 148 | const loremIpsum = loremIpsumCache.value(); 149 | const i = Math.floor(Math.random() * loremIpsum.length); 150 | return loremIpsum[i]; 151 | }; 152 | 153 | export const randomScopes = (limit: number) => { 154 | const scopes = []; 155 | for (let i = 0; i < limit; i++) { 156 | scopes.push(`${randomWord()}-${randomWord()}`); 157 | } 158 | 159 | return toHashMap(scopes); 160 | }; 161 | 162 | export const compareClaimAgainstInput = async (claim: HyperCertMinter.ClaimStructOutput, options: Claim) => { 163 | expect(claim.rights).to.be.eql(options.rights); 164 | expect(claim.version).to.be.eq(options.version); 165 | 166 | expect(claim.contributors.map(address => address.toLowerCase())).to.be.eql( 167 | options.contributors.map(addr => addr.toLowerCase()), 168 | ); 169 | expect(claim.workTimeframe.map(timestamp => timestamp.toNumber())).to.be.eql(options.workTimeframe); 170 | expect(claim.workScopes).to.be.eql(options.workScopes); 171 | 172 | expect(claim.impactTimeframe.map(timestamp => timestamp.toNumber())).to.be.eql(options.impactTimeframe); 173 | expect(claim.impactScopes).to.be.eql(options.impactScopes); 174 | }; 175 | 176 | export const decode64 = (value: string, header: boolean = true) => { 177 | const base64String = () => (header ? value.substring(value.indexOf("base64,") + 7) : value); 178 | return utils.toUtf8String(utils.base64.decode(base64String())); 179 | }; 180 | 181 | export const subScopeKeysForValues = (claim: Claim, impactScopes: Dictionary | string[]) => { 182 | const isArray = (obj: Dictionary | string[]): obj is string[] => typeof obj["slice"] === "function"; 183 | const getValues = (scopes: Dictionary | string[]) => (isArray(scopes) ? scopes : Object.values(scopes)); 184 | 185 | return { 186 | rights: claim.rights, 187 | workTimeframe: claim.workTimeframe, 188 | impactTimeframe: claim.impactTimeframe, 189 | contributors: claim.contributors, 190 | name: claim.name, 191 | description: claim.description, 192 | uri: claim.uri, 193 | version: claim.version, 194 | fractions: claim.fractions, 195 | impactScopes: getValues(impactScopes), 196 | workScopes: claim.workScopes, 197 | }; 198 | }; 199 | 200 | const sum = (series: number[]) => { 201 | return series.reduce((previousValue, currentValue) => previousValue + currentValue); 202 | }; 203 | 204 | export const validateMetadata = async (metadata64: string, expected: string | Claim, units?: number) => { 205 | expect(metadata64.startsWith(DataApplicationJson)).to.be.true; 206 | const metadataJson = decode64(metadata64); 207 | if (typeof expected === "string") expect(metadataJson).to.include(expected); 208 | if (typeof expected === "object") { 209 | try { 210 | const metadata = JSON.parse(metadataJson); 211 | 212 | expect(metadata.name).to.eq(expected.name); //slice because of string splitting 213 | expect(metadata.description).to.eq(expected.description); 214 | await validateSVG( 215 | decode64(metadata.image), 216 | { ...expected, units, totalUnits: sum(expected.fractions) }, 217 | units !== undefined, 218 | ); 219 | expect(metadata.external_url).to.eq(expected.uri); 220 | } catch (error) { 221 | console.error(error, metadataJson); 222 | throw error; 223 | } 224 | } 225 | }; 226 | 227 | const formatDate = (unix: number) => format(new Date(unix * 1000), "yyyy-M-d"); 228 | const formatTimeframe = (timeframe: [number, number]) => `${formatDate(timeframe[0])} to ${formatDate(timeframe[1])}`; 229 | const formatPercent = (units: number, totalUnits: number) => { 230 | const percentage = ((units / totalUnits) * 100).toLocaleString("en-us", { 231 | minimumFractionDigits: 2, 232 | maximumFractionDigits: 2, 233 | }); 234 | return `${percentage} %`; 235 | }; 236 | const truncate = (scope: string, maxLength: number = 23) => 237 | scope.length <= maxLength ? scope : `${scope.substring(0, maxLength - 3)}...`; 238 | 239 | export const validateSVG = async (svg: string, expected: SVGInput, fraction: boolean = false) => { 240 | const baseUrl = "src/xsd/"; 241 | const xsd = await fs.readFile(`${baseUrl}svg.xsd`, { encoding: "utf-8" }); 242 | const xsdDoc = parseXml(xsd, { baseUrl }); 243 | const svgDoc = parseXml(svg); 244 | svgDoc.validate(xsdDoc); 245 | 246 | const nameParts = expected.name.split(" "); 247 | const svgName = svgDoc.get("//*[@id='name-color']")?.text(); 248 | for (let i = 0; i < Math.min(nameParts.length, 2); i++) { 249 | expect(svgName).to.contain(nameParts[i].substring(0, 10), `Name "${nameParts[i]}" not found: ${svg}`); 250 | } 251 | 252 | const truncScope = truncate(expected.impactScopes[0]); 253 | expect(svgDoc.get("//*[@id='scope-impact-color']")?.text()).to.eq( 254 | truncScope, 255 | `Scope "${truncScope}" not found: ${svg}`, 256 | ); 257 | 258 | expect(svgDoc.get("//*[@id='work-period-color']")?.text()).to.eq( 259 | formatTimeframe(expected.workTimeframe), 260 | `Work period not found: ${svg}`, 261 | ); 262 | 263 | if (fraction && expected.units) { 264 | const percentage = formatPercent(expected.units, expected.totalUnits); 265 | expect(svgDoc.get("//*[@id='fraction-color']")?.text()).to.eq( 266 | percentage, 267 | `Percentage ${percentage} not found: ${svg}`, 268 | ); 269 | } 270 | 271 | expect(svgDoc.validationErrors.length).to.eq(0, svgDoc.validationErrors.join("\n")); 272 | }; 273 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "emitDecoratorMetadata": true, 6 | "esModuleInterop": true, 7 | "experimentalDecorators": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "lib": ["es6"], 10 | "module": "commonjs", 11 | "moduleResolution": "node", 12 | "noImplicitAny": true, 13 | "removeComments": true, 14 | "resolveJsonModule": true, 15 | "sourceMap": true, 16 | "strict": true, 17 | "target": "es6" 18 | }, 19 | "exclude": ["node_modules"], 20 | "files": ["./hardhat.config.ts"], 21 | "include": ["src/**/*", "tasks/**/*", "test/**/*", "./deploy/**/*"] 22 | } 23 | --------------------------------------------------------------------------------