├── keys └── .keep ├── tmp └── .keep ├── .envrc ├── output ├── nft_images │ └── .keep ├── vc_images │ └── .keep ├── blockchain_certificates │ └── .keep └── unsigned_certificates │ └── .keep ├── .python-version ├── .solhint.json ├── hostings ├── staging │ ├── public │ │ ├── robots.txt │ │ ├── blockcerts_revocation_list.json │ │ ├── blockcerts.json │ │ └── .well-known │ │ │ └── did.json │ ├── rsync.sh │ ├── cors_setting.json │ └── README.md └── production │ ├── public │ └── robots.txt │ ├── rsync.sh │ └── cors_setting.json ├── requirements.txt ├── cert-issuer.prd.ini ├── arguments.js ├── .vscode └── settings.json ├── cert-issuer.dev.ini ├── templates ├── nft-test.svg ├── fonts │ ├── OFL.txt │ └── NotoSansJP-Light.base64.txt └── vc-test.svg ├── scripts ├── vc-checker.js ├── convert-did-public-key.js ├── convert-base64-image.js ├── deploy.js ├── convert-members.js ├── generate-nft-image.js ├── bulk-upload-to-ipfs.js ├── generate-unsigned-vc.js ├── generate-vc-image.js ├── bulk-mint.js └── utils.js ├── LICENSE ├── package.json ├── hardhat.config.js ├── .env.sample ├── senario.md ├── tests ├── scripts │ └── utils-test.js └── contracts │ └── nft-credential-test.js ├── README.md ├── .gitignore └── contracts └── NFTCredential.sol /keys/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | dotenv 2 | -------------------------------------------------------------------------------- /output/nft_images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /output/vc_images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.9.13 2 | -------------------------------------------------------------------------------- /output/blockchain_certificates/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /output/unsigned_certificates/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:default" 3 | } 4 | -------------------------------------------------------------------------------- /hostings/staging/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /hostings/production/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /hostings/staging/rsync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | gsutil rsync -d -r ./public $GCS_BUCKET_NAME_DEVELOPMENT 3 | -------------------------------------------------------------------------------- /hostings/production/rsync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | gsutil rsync -d -r ./public $GCS_BUCKET_NAME_PRODCUTION 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cert-issuer==3.2.0 2 | web3<=4.4.1 3 | coincurve==17.0.0 4 | ethereum==2.3.1 5 | rlp<1 6 | eth-account<=0.3.0 7 | -------------------------------------------------------------------------------- /hostings/production/cors_setting.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "origin": ["*"], 4 | "method": ["GET"], 5 | "responseHeader": ["Content-Type"], 6 | "maxAgeSeconds": 3600 7 | } 8 | ] -------------------------------------------------------------------------------- /hostings/staging/cors_setting.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "origin": ["*"], 4 | "method": ["GET"], 5 | "responseHeader": ["Content-Type"], 6 | "maxAgeSeconds": 3600 7 | } 8 | ] -------------------------------------------------------------------------------- /cert-issuer.prd.ini: -------------------------------------------------------------------------------- 1 | issuing_address= 2 | usb_name=./keys 3 | key_file=wallet-private.prd.key 4 | unsigned_certificates_dir=./output/unsigned_certificates 5 | blockchain_certificates_dir=./output/blockchain_certificates 6 | work_dir=./tmp 7 | no_safe_mode 8 | verification_method= 9 | -------------------------------------------------------------------------------- /arguments.js: -------------------------------------------------------------------------------- 1 | // npx hardhat verify --constructor-args arguments.js DEPLOYED_CONTRACT_ADDRESS 2 | const { 3 | MAX_BATCH_SIZE, 4 | getTokenName, 5 | getTokenSymbol, 6 | } = require("./scripts/utils") 7 | 8 | module.exports = [getTokenName(), getTokenSymbol(), MAX_BATCH_SIZE] 9 | -------------------------------------------------------------------------------- /hostings/staging/public/blockcerts_revocation_list.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://w3id.org/openbadges/v2", 3 | "id": "https://nft-vc.example.com/blockcerts_revocation_list.json", 4 | "type": "RevocationList", 5 | "issuer": "https://nft-vc.example.com/blockcerts.json", 6 | "revokedAssertions": [] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true, 4 | "source.organizeImports": true 5 | }, 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "editor.formatOnSave": true, 8 | "editor.tabSize": 2, 9 | "files.exclude": { 10 | "**/node_modules": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /cert-issuer.dev.ini: -------------------------------------------------------------------------------- 1 | issuing_address=0x1234567890123456789012345678901234567890 2 | usb_name=./keys 3 | key_file=wallet-private.dev.key 4 | unsigned_certificates_dir=./output/unsigned_certificates 5 | blockchain_certificates_dir=./output/blockchain_certificates 6 | work_dir=./tmp 7 | no_safe_mode 8 | verification_method=did:web:nft-vc.example.com#key-1 9 | -------------------------------------------------------------------------------- /templates/nft-test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Test NFT 5 | -------------------------------------------------------------------------------- /hostings/staging/README.md: -------------------------------------------------------------------------------- 1 | ## hosting 方法について 2 | 3 | サンプルでは、GCP の `Cloud Storage`, `Cloud Load Balancing`, `Cloud CDN`を利用して構築しています。 4 | `rsync.sh`,`cors設定`は、`Cloud Storage`へのアップロードを前提としたスクリプトになります。 5 | 参照: https://cloud.google.com/storage/docs/hosting-static-website#console_1 6 | 7 | ## ファイル同期方法 8 | 9 | ```sh 10 | sh ./rsync.sh 11 | ``` 12 | 13 | ## CORS 設定 14 | 15 | ```sh 16 | gsutil cors set cors_setting.json $GCS_BUCKET_NAME_DEVELOPMENT 17 | ``` 18 | -------------------------------------------------------------------------------- /scripts/vc-checker.js: -------------------------------------------------------------------------------- 1 | // 2 | // for decode proofValue and check 3 | // 4 | // How to use: 5 | // $ node scripts/vc-checker {{proofValue}} 6 | // 7 | 8 | const { Decoder } = require('@vaultie/lds-merkle-proof-2019') 9 | 10 | if (process.argv.length !== 3) { 11 | console.log("[ERROR] must input proof value.") 12 | return 13 | } 14 | 15 | const proofValueBase58 = process.argv[2] 16 | const decoder = new Decoder(proofValueBase58) 17 | console.log(decoder.decode()) 18 | -------------------------------------------------------------------------------- /scripts/convert-did-public-key.js: -------------------------------------------------------------------------------- 1 | // 2 | // convert into public key(JWK) from Ethereum's private key 3 | // 4 | // How to use: 5 | // $ node scripts/convert-did-public-key.js 6 | // 7 | // e.g. 8 | // $ node scripts/convert-did-public-key.js ./keys/wallet-private.dev.key 9 | // 10 | 11 | const fs = require("fs") 12 | const keyto = require("@trust/keyto") 13 | 14 | const keyFilepath = process.argv[2] 15 | const privateKey = fs.readFileSync(keyFilepath, "utf-8") 16 | const keyJwk = keyto.from(privateKey, "blk").toJwk("public") 17 | 18 | console.log(JSON.stringify(keyJwk)) 19 | -------------------------------------------------------------------------------- /scripts/convert-base64-image.js: -------------------------------------------------------------------------------- 1 | // 2 | // convert into base64 from a image file 3 | // 4 | // How to use: 5 | // $ node scripts/convert-base64-image.js 6 | // 7 | // e.g. 8 | // $ node scripts/convert-base64-image.js ./examples/logo.jpg jpeg 9 | // $ node scripts/convert-base64-image.js ./examples/logo.png png 10 | // 11 | 12 | const imageFilepath = process.argv[2] 13 | const mimeImageType = process.argv[3] 14 | 15 | const { encodeBase64FromImage } = require("./utils") 16 | const base64Data = encodeBase64FromImage(imageFilepath, mimeImageType) 17 | console.log(base64Data) 18 | -------------------------------------------------------------------------------- /hostings/staging/public/blockcerts.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": [ 3 | "https://w3id.org/openbadges/v2", 4 | "https://w3id.org/blockcerts/3.0" 5 | ], 6 | "type": "Profile", 7 | "id": "https://nft-vc.example.com/blockcerts.json", 8 | "name": "nft-vc.example, Inc.", 9 | "url": "https://nft-vc.example.com/", 10 | "publicKey": [ 11 | { 12 | "id": "ecdsa-koblitz-pubkey:0x1234567890123456789012345678901234567890", 13 | "created": "2022-10-10T10:00:00.000000+00:00" 14 | } 15 | ], 16 | "revocationList": "https://nft-vc.example.com/blockcerts_revocation_list.json", 17 | "image": "" 18 | } 19 | -------------------------------------------------------------------------------- /hostings/staging/public/.well-known/did.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "did:web:did.staging.sakazuki.xyz", 3 | "verificationMethod": [ 4 | { 5 | "id": "#key-1", 6 | "controller": "did:web:nft-vc.example.com", 7 | "type": "EcdsaSecp256k1VerificationKey2019", 8 | "publicKeyJwk": { 9 | "kty": "EC", 10 | "crv": "K-256", 11 | "x": "XyZpmS5rwy23Dqm3iYNGn_A_p5JYXOSbyp_ev1Uss7E", 12 | "y": "XyZvZO81bLTeH0-XmjQlrhAWOC79utg3aC5BV5amAsI" 13 | } 14 | } 15 | ], 16 | "service": [ 17 | { 18 | "id": "#service-1", 19 | "type": "IssuerProfile", 20 | "serviceEndpoint": "https://nft-vc.example.com/blockcerts.json" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /scripts/deploy.js: -------------------------------------------------------------------------------- 1 | // 2 | // deploys NFTCredential contract. 3 | // 4 | // How to use: 5 | // $ npx hardhat run scripts/deploy.js --network rinkeby|goerli|mainnet 6 | // 7 | 8 | const hre = require("hardhat") 9 | const { MAX_BATCH_SIZE, getTokenName, getTokenSymbol } = require("./utils") 10 | 11 | const main = async () => { 12 | const contractFactory = await hre.ethers.getContractFactory("NFTCredential") 13 | const contract = await contractFactory.deploy(getTokenName(), getTokenSymbol(), MAX_BATCH_SIZE) 14 | await contract.deployed() 15 | console.log("deployed to:", contract.address) 16 | } 17 | 18 | main() 19 | .then(() => process.exit(0)) 20 | .catch((error) => { 21 | console.error(error) 22 | process.exit(1) 23 | }) 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 PitPa 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nft-vc", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/mocha/bin/mocha.js tests/scripts", 8 | "test_contracts": "npx hardhat test --network localhost" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@vaultie/lds-merkle-proof-2019": "^0.0.9" 15 | }, 16 | "devDependencies": { 17 | "@alch/alchemy-web3": "^1.4.4", 18 | "@nomiclabs/hardhat-ethers": "^2.1.0", 19 | "@nomiclabs/hardhat-etherscan": "^3.1.0", 20 | "@nomiclabs/hardhat-waffle": "^2.0.3", 21 | "@openzeppelin/contracts": "^4.7.0", 22 | "@pinata/sdk": "^1.1.26", 23 | "@trust/keyto": "^1.0.1", 24 | "canvas": "^2.9.3", 25 | "canvg": "^4.0.1", 26 | "chai": "^4.3.6", 27 | "csvtojson": "^2.0.10", 28 | "dotenv": "^16.0.1", 29 | "erc721a": "^4.2.0", 30 | "ethereum-waffle": "^3.2.0", 31 | "ethers": "^5.6.9", 32 | "hardhat": "^2.10.0", 33 | "mocha": "^10.0.0", 34 | "node-canvas": "^2.9.0", 35 | "uuid": "^8.3.2", 36 | "xmldom": "^0.6.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | require("@nomiclabs/hardhat-waffle") 2 | require("@nomiclabs/hardhat-etherscan") 3 | require("dotenv").config() 4 | 5 | // https://hardhat.org/guides/create-task.html 6 | task("accounts", "Prints the list of accounts", async (taskArgs, hre) => { 7 | const accounts = await hre.ethers.getSigners() 8 | 9 | for (const account of accounts) { 10 | console.log(account.address) 11 | } 12 | }) 13 | 14 | /** 15 | * @type import('hardhat/config').HardhatUserConfig 16 | */ 17 | module.exports = { 18 | solidity: "0.8.4", 19 | defaultNetwork: "goerli", 20 | networks: { 21 | goerli: { 22 | url: process.env.GOERLI_ALCHEMY_URL, 23 | accounts: [process.env.PRIVATE_KEY], 24 | }, 25 | mainnet: { 26 | url: process.env.MAINNET_ALCHEMY_URL, 27 | accounts: [process.env.PRIVATE_KEY], 28 | }, 29 | polygonMumbai: { 30 | url: process.env.MUMBAI_ALCHEMY_URL, 31 | accounts: [process.env.PRIVATE_KEY], 32 | }, 33 | polygon: { 34 | url: process.env.POLYGON_ALCHEMY_URL, 35 | accounts: [process.env.PRIVATE_KEY], 36 | }, 37 | }, 38 | etherscan: { 39 | apiKey: { 40 | goerli: process.env.ETHERSCAN_API_TOKEN, 41 | mainnet: process.env.ETHERSCAN_API_TOKEN, 42 | polygonMumbai: process.env.POLYGONSCAN_API_TOKEN, 43 | polygon: process.env.POLYGONSCAN_API_TOKEN, 44 | }, 45 | }, 46 | paths: { 47 | tests: "./tests/contracts", 48 | }, 49 | mocha: { 50 | timeout: 1000 * 60 * 3, // 3min 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Use Ethereum chain: 2 | # development: "goerli" 3 | # production: "mainnet" 4 | # Use Polygon chain: 5 | # development: "polygonMumbai" 6 | # production: "polygon" 7 | NODE_ENV="" 8 | 9 | # Set uploaded contract adress. 10 | # ./README.md > ### NFT 発行フロー > 3. コントラクトのデプロイ 11 | CONTRACT_ADDRESS="" 12 | 13 | # www.alchemy.com (Ethereum) 14 | MAINNET_ALCHEMY_URL="" 15 | GOERLI_ALCHEMY_URL="" 16 | # www.alchemy.com (Polygon) 17 | POLYGON_ALCHEMY_URL="" 18 | MUMBAI_ALCHEMY_URL="" 19 | 20 | # IPFS adress for upload vc,nft,vc-image 21 | # default: https://gateway.pinata.cloud/ipfs/ 22 | # www.pinata.cloud 23 | PINATA_PRODUCTION_IPFS_URL_PREFIX="" 24 | PINATA_DEVELOPMENT_IPFS_URL_PREFIX="" 25 | PINATA_API_KEY="" 26 | PINATA_SECRET_API_KEY="" 27 | 28 | # etherscan.io apikey (use when cert-issuer and uploading the contract code) 29 | ETHERSCAN_API_TOKEN="" 30 | # polygonscan.com apikey (use when uploading the contract code) 31 | POLYGONSCAN_API_TOKEN="" 32 | 33 | # verify host 34 | VERIFY_HOST_URL= 35 | VERIFY_HOST_URL_DEV="" 36 | 37 | # issuerDID 38 | ISSUER_DID_DEV="" 39 | ISSUER_DID_PRD="" 40 | 41 | # Admin's wallet 42 | # development: 43 | # - public: 0x1234567890123456789012345678901234567890 44 | # - private: refer to ./keys/wallet-private.dev.key 45 | PUBLIC_KEY="" 46 | PRIVATE_KEY="" 47 | 48 | # Issuer Bucket 49 | # If you hosting did.json and blockcerts.json to GCP Cloud Storage you can use this variables. 50 | # ex) gs://nft-vc-development.com 51 | GCS_BUCKET_NAME_DEVELOPMENT="" 52 | GCS_BUCKET_NAME_PRODCUTION="" 53 | -------------------------------------------------------------------------------- /senario.md: -------------------------------------------------------------------------------- 1 | # シナリオ 2 | 3 | - 一郎さん、二郎さん、三郎さんの 3 名が、 4 | 講義の単位を取得し証明書を VC 付きの NFT として受け取ります。 5 | - NFT を opensea 上で参照し、blockcerts を用いて検証を行います。 6 | 7 | # 事前準備 8 | 9 | - did.json / blockcerts の IssuerProfile は公開済み 10 | 11 | - [did.json](https://did.staging.sakazuki.xyz/.well-known/did.json) 12 | - [blockcerts.json](https://did.staging.sakazuki.xyz/blockcerts.json) 13 | 14 | - 証明書画像 / NFT 画像については、事前に作成済み 15 | 16 | - 証明書画像: ./output/vc_images 17 | - NFT 画像: ./output/nft_images 18 | 19 | - NFT の mint 用のコントラクトは事前にアップロード済み 20 | 21 | 上記が出来ている状態から、VC の作成と 22 | NFT の発行・発行物の確認までを実施します。 23 | 24 | # VC(Verifiable Credentials)の発行 25 | 26 | 1.署名なしの VC の作成 27 | 28 | ``` 29 | # 入力情報の確認(事前生成) 30 | cat ./tmp/members.json | jq 31 | # VCの作成 32 | node scripts/generate-unsigned-vc.js ./tmp/members.json 33 | ``` 34 | 35 | 2.VC 発行をします 36 | 37 | ``` 38 | cert-issuer -c cert-issuer.dev.ini --chain ethereum_goerli --goerli_rpc_url $GOERLI_ALCHEMY_URL 39 | ``` 40 | 41 | 3.IPFS にアップロードする 42 | 43 | ``` 44 | node scripts/bulk-upload-to-ipfs.js ./tmp/members.json vc 45 | # アップロードhashの確認 46 | cat ./tmp/members.json | jq 47 | ``` 48 | 49 | - https://gateway.pinata.cloud/ipfs/XXXX 50 | 51 | # NFT の発行 52 | 53 | 1.NFT 画像を IPFS にアップロードする 54 | 55 | ``` 56 | # NFT画像の確認 57 | open ./output/nft_images/ 58 | # アップロード処理 59 | node scripts/bulk-upload-to-ipfs.js ./tmp/members.json nft 60 | # アップロードした内容の確認 61 | cat ./tmp/members.json | jq 62 | ``` 63 | 64 | 2.NFT の mint 65 | アップロードした NFT を対象者へ送る 66 | 67 | ``` 68 | # dry run 69 | node scripts/bulk-mint.js ./tmp/members.json 70 | # 本実施 71 | node scripts/bulk-mint.js ./tmp/members.json --dry-run=false 72 | ``` 73 | 74 | 3.opensea で確認を実施 75 | https://testnets.opensea.io/ja/assets/goerli/0xdd9d3bd00f4617a4425f55d32ec41c7dbd82f8c2/15 76 | \*VC の確認。Blockcerts での検証結果確認 77 | -------------------------------------------------------------------------------- /scripts/convert-members.js: -------------------------------------------------------------------------------- 1 | // 2 | // convert into JSON from Google Form CSV. 3 | // 4 | // How to use: 5 | // $ node scripts/convert-members.js 6 | // 7 | // e.g. 8 | // $ node scripts/convert-members.js ./tmp/form.csv > ./tmp/members.json 9 | // 10 | 11 | const csv = require("csvtojson") 12 | const uuidv4 = require("uuid").v4 13 | 14 | const fieldNameMap = { 15 | LECTURE_ID: "credential_id", 16 | LECTURE_NAME: "credential_name", 17 | STUDENT_ID: "holder_id", 18 | STUDENT_NAME: "holder_name", 19 | WALLET_ADDRESS: "holder_wallet_address", 20 | } 21 | 22 | const ETHEREUM_WALLET_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/ 23 | function isEthereumAddress(address) { 24 | return !!address.match(ETHEREUM_WALLET_ADDRESS_RE) 25 | } 26 | 27 | async function main() { 28 | if (process.argv.length !== 3) { 29 | console.log("[ERROR] input file must be specified.") 30 | return 31 | } 32 | const inputCsv = process.argv[2] 33 | const rawItems = await csv().fromFile(inputCsv) 34 | 35 | const items = [] 36 | rawItems.forEach((rawItem) => { 37 | const item = { id: uuidv4() } 38 | Object.keys(fieldNameMap).forEach((rawField) => { 39 | const field = fieldNameMap[rawField] 40 | let value = rawItem[rawField] 41 | if (field === "holder_name") { 42 | value = value.replaceAll(" ", " ").replaceAll(/ +/g, " ") 43 | } 44 | rawField 45 | if (field === "holder_wallet_address") { 46 | if (!isEthereumAddress(value)) { 47 | console.error( 48 | `(WARNING LINE=${items.length + 1}) The address is INVALID.` 49 | ) 50 | } 51 | } 52 | item[field] = value 53 | }) 54 | items.push(item) 55 | }) 56 | return items 57 | } 58 | 59 | main() 60 | .then((items) => { 61 | console.log(JSON.stringify(items)) 62 | process.exit(0) 63 | }) 64 | .catch((error) => { 65 | console.error(error) 66 | process.exit(1) 67 | }) 68 | -------------------------------------------------------------------------------- /scripts/generate-nft-image.js: -------------------------------------------------------------------------------- 1 | // 2 | // generate NFT images from templates/nft.svg and members.json 3 | // 4 | // How to use: 5 | // $ node scripts/generate-nft-image.js 6 | // 7 | // e.g. 8 | // $ node scripts/generate-nft-image.js ./tmp/members.json 9 | 10 | const fs = require("fs") 11 | const path = require("path") 12 | 13 | const canvas = require("canvas") 14 | const canvg = require("canvg") 15 | const fetch = require("node-fetch") 16 | const xmldom = require("xmldom") 17 | 18 | const [WIDTH, HEIGHT] = [700, 700] 19 | const OUTPUT_IMAGE_DIR = path.join(__dirname, "../output/nft_images") 20 | const { cleanupOutputDir, isProduction } = require("./utils") 21 | 22 | const getTemplateFileName = (credentialId) => { 23 | switch (credentialId) { 24 | // credentialIdによって、テンプレート変更する場合に条件追加 25 | default: 26 | return "nft-test.svg" 27 | } 28 | } 29 | 30 | const preset = canvg.presets.node({ 31 | DOMParser: xmldom.DOMParser, 32 | canvas, 33 | fetch, 34 | }) 35 | 36 | const generate = async (member) => { 37 | const outputFile = path.join(OUTPUT_IMAGE_DIR, `${member.id}.png`) 38 | 39 | const templateFileName = getTemplateFileName(member.credential_id) 40 | const templatePath = path.join( 41 | __dirname, 42 | `./../templates/${templateFileName}` 43 | ) 44 | 45 | if (templatePath.endsWith(".svg")) { 46 | const svg = fs.readFileSync(templatePath, "utf8") 47 | // convert SVG to PNG 48 | const c = preset.createCanvas(WIDTH, HEIGHT) 49 | const ctx = c.getContext("2d") 50 | const v = canvg.Canvg.fromString(ctx, svg, preset) 51 | await v.render() 52 | const png = c.toBuffer() 53 | 54 | fs.writeFileSync(outputFile, png) 55 | } else { 56 | fs.copyFileSync(templatePath, outputFile) 57 | } 58 | 59 | return outputFile 60 | } 61 | 62 | const main = async () => { 63 | if (process.argv.length !== 3) { 64 | console.log("[ERROR] input file and output dir must be specified.") 65 | return 66 | } 67 | 68 | const membersJsonPath = process.argv[2] 69 | const members = JSON.parse(fs.readFileSync(membersJsonPath, "utf8")) 70 | await cleanupOutputDir(OUTPUT_IMAGE_DIR, ".png") 71 | 72 | let cnt = 0 73 | const total = members.length 74 | for (const member of members) { 75 | cnt += 1 76 | console.log(`[${cnt}/${total}] Generating a NFT image: ${member.id}`) 77 | const imagePath = await generate(member) 78 | member.nft_image_path = imagePath 79 | } 80 | 81 | // update members.json to add the generated image paths 82 | fs.writeFileSync(membersJsonPath, JSON.stringify(members)) 83 | } 84 | 85 | main() 86 | .then(() => process.exit(0)) 87 | .catch((error) => { 88 | console.error(error) 89 | process.exit(1) 90 | }) 91 | -------------------------------------------------------------------------------- /scripts/bulk-upload-to-ipfs.js: -------------------------------------------------------------------------------- 1 | // 2 | // bulk upload VCs or NFT images to IPFS 3 | // 4 | // How to use: 5 | // $ node scripts/bulk-upload-to-ipfs.js 6 | // 7 | // e.g. 8 | // # UPLOAD_TYPE=vc 9 | // $ node scripts/bulk-upload-to-ipfs.js ./tmp/members.json vc 10 | // 11 | // # UPLOAD_TYPE=nft 12 | // $ node scripts/bulk-upload-to-ipfs.js ./tmp/members.json nft 13 | // 14 | 15 | const fs = require("fs") 16 | const pinataSDK = require("@pinata/sdk") 17 | 18 | const { 19 | PINATA_API_KEY, 20 | PINATA_SECRET_API_KEY, 21 | } = process.env 22 | const pinata = pinataSDK(PINATA_API_KEY, PINATA_SECRET_API_KEY) 23 | 24 | const IPFS_FILE_NAME_PREFIX = { 25 | vc: "test-vc", 26 | nft: "test-nft", 27 | } 28 | 29 | UPLOAD_TYPE_VC = "vc" 30 | UPLOAD_TYPE_NFT = "nft" 31 | 32 | async function main() { 33 | if (process.argv.length !== 4) { 34 | console.log("[ERROR] input file must be specified.") 35 | return 36 | } 37 | 38 | if (!PINATA_API_KEY || !PINATA_SECRET_API_KEY) { 39 | console.log("[ERROR] PINATA_API_KEY or PINATA_SECRET_API_KEY is not set.") 40 | return 41 | } 42 | 43 | const membersJsonPath = process.argv[2] 44 | const members = JSON.parse(fs.readFileSync(membersJsonPath, "utf8")) 45 | 46 | const uploadType = process.argv[3].toLowerCase() 47 | if (![UPLOAD_TYPE_VC, UPLOAD_TYPE_NFT].includes(uploadType)) { 48 | console.log( 49 | `[ERROR] unknown UPLOAD_TYPE='${uploadType}'. UPLOAD_TYPE is only support for '${UPLOAD_TYPE_VC}' or '${UPLOAD_TYPE_NFT}'.` 50 | ) 51 | return 52 | } 53 | 54 | const ipfsNamePrefix = IPFS_FILE_NAME_PREFIX[uploadType] 55 | let filePathField 56 | let outputField 57 | if (UPLOAD_TYPE_VC === uploadType) { 58 | filePathField = "vc_path" 59 | outputField = "vc_ipfs_hash" 60 | } else { 61 | filePathField = "nft_image_path" 62 | outputField = "nft_image_ipfs_hash" 63 | } 64 | 65 | const total = members.length 66 | let cnt = 0 67 | const results = [] 68 | for (const member of members) { 69 | cnt += 1 70 | const ipfsName = `${ipfsNamePrefix}-${member.id}`.slice(0, 45) // Pinata spec. max length: 50 71 | console.log( 72 | `[${cnt}/${total}] Uploading to IPFS: vc=${member.id}, name=${ipfsName}` 73 | ) 74 | const stream = fs.createReadStream(member[filePathField]) 75 | const ipfsHash = ( 76 | await pinata.pinFileToIPFS(stream, { 77 | pinataMetadata: { 78 | name: ipfsName, 79 | }, 80 | pinataOptions: { 81 | cidVersion: 0, 82 | }, 83 | }) 84 | ).IpfsHash 85 | member[outputField] = ipfsHash 86 | results.push(ipfsHash) 87 | } 88 | // update members.json to add the URLs on IPFS 89 | fs.writeFileSync(membersJsonPath, JSON.stringify(members)) 90 | return results 91 | } 92 | 93 | main() 94 | .then(() => process.exit(0)) 95 | .catch((error) => { 96 | console.error(error) 97 | process.exit(1) 98 | }) 99 | -------------------------------------------------------------------------------- /tests/scripts/utils-test.js: -------------------------------------------------------------------------------- 1 | const assert = require("chai").assert 2 | const utils = require("./../../scripts/utils") 3 | 4 | describe("utils.createMintBatches", function () { 5 | const item1 = { 6 | vc_id: "VC001", 7 | credential_id: "C001", 8 | holder_id: "H001", 9 | } 10 | const item2 = { 11 | vc_id: "VC002", 12 | credential_id: "C001", 13 | holder_id: "H002", 14 | } 15 | const item3 = { 16 | vc_id: "VC003", 17 | credential_id: "C001", 18 | holder_id: "H003", 19 | } 20 | const item4 = { 21 | vc_id: "VC004", 22 | credential_id: "C001", 23 | holder_id: "H004", 24 | } 25 | const item5 = { 26 | vc_id: "VC005", 27 | credential_id: "C002", 28 | holder_id: "H001", 29 | } 30 | const item6 = { 31 | vc_id: "VC006", 32 | credential_id: "C002", 33 | holder_id: "H005", 34 | } 35 | const item7 = { 36 | vc_id: "VC007", 37 | credential_id: "C002", 38 | holder_id: "H006", 39 | } 40 | const item8 = { 41 | vc_id: "VC008", 42 | credential_id: "C003", 43 | holder_id: "H001", 44 | } 45 | const members1 = [item1, item2, item3, item4, item5, item6, item7, item8] 46 | it("batchSize=1", function () { 47 | const actual = utils.createMintBatches({ members: members1, batchSize: 1 }) 48 | assert.equal(actual.length, 8) 49 | assert.sameMembers(actual[0], [item1]) 50 | assert.sameMembers(actual[1], [item2]) 51 | assert.sameMembers(actual[2], [item3]) 52 | assert.sameMembers(actual[3], [item4]) 53 | assert.sameMembers(actual[4], [item5]) 54 | assert.sameMembers(actual[5], [item6]) 55 | assert.sameMembers(actual[6], [item7]) 56 | assert.sameMembers(actual[7], [item8]) 57 | }) 58 | 59 | it("batchSize=3", function () { 60 | const actual = utils.createMintBatches({ members: members1, batchSize: 3 }) 61 | assert.equal(actual.length, 4) 62 | assert.sameMembers(actual[0], [item1, item2, item3]) 63 | assert.sameMembers(actual[1], [item4]) 64 | assert.sameMembers(actual[2], [item5, item6, item7]) 65 | assert.sameMembers(actual[3], [item8]) 66 | }) 67 | 68 | it("batchSize=10", function () { 69 | const actual = utils.createMintBatches({ 70 | members: members1, 71 | batchSize: 10, 72 | }) 73 | assert.equal(actual.length, 3) 74 | assert.sameMembers(actual[0], [item1, item2, item3, item4]) 75 | assert.sameMembers(actual[1], [item5, item6, item7]) 76 | assert.sameMembers(actual[2], [item8]) 77 | }) 78 | 79 | it(`default batchSize=${utils.MAX_BATCH_SIZE}`, function () { 80 | const actual = utils.createMintBatches({ 81 | members: members1, 82 | }) 83 | assert.equal(actual.length, 3) 84 | assert.sameMembers(actual[0], [item1, item2, item3, item4]) 85 | assert.sameMembers(actual[1], [item5, item6, item7]) 86 | assert.sameMembers(actual[2], [item8]) 87 | }) 88 | 89 | it("batchSize=3 and groupField=holder_id", function () { 90 | const actual = utils.createMintBatches({ 91 | members: members1, 92 | batchSize: 3, 93 | groupField: "holder_id", 94 | }) 95 | assert.equal(actual.length, 6) 96 | assert.sameMembers(actual[0], [item1, item5, item8]) 97 | assert.sameMembers(actual[1], [item2]) 98 | assert.sameMembers(actual[2], [item3]) 99 | assert.sameMembers(actual[3], [item4]) 100 | assert.sameMembers(actual[4], [item6]) 101 | assert.sameMembers(actual[5], [item7]) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /scripts/generate-unsigned-vc.js: -------------------------------------------------------------------------------- 1 | // 2 | // generate unsigned VCs from members.json. 3 | // 4 | // How to use: 5 | // $ node scripts/generate-unsigned-vc.js 6 | // 7 | // e.g. 8 | // $ node scripts/generate-unsigned-vc.js ./tmp/members.json 9 | // 10 | 11 | const fs = require("fs") 12 | const path = require("path") 13 | const uuidv4 = require("uuid").v4 14 | 15 | const { 16 | ISSUANCE_DATE, 17 | isProduction, 18 | cleanupOutputDir, 19 | getTokenDescription, 20 | getTokenJapaneseDescription, 21 | } = require("./utils") 22 | 23 | const { 24 | ISSUER_DID_PRD, 25 | ISSUER_DID_DEV 26 | } = process.env 27 | 28 | const OUPUT_UNSIGNED_VC_DIR = path.join( 29 | __dirname, 30 | "../output/unsigned_certificates" 31 | ) 32 | const OUPUT_SIGNED_VC_DIR = path.join( 33 | __dirname, 34 | "../output/blockchain_certificates" 35 | ) 36 | const issuerDID = isProduction() 37 | ? ISSUER_DID_PRD 38 | : ISSUER_DID_DEV 39 | const UUID_PREFIX = "arn:uuid:" 40 | 41 | const TEMPLATE_VC = { 42 | "@context": [ 43 | "https://www.w3.org/2018/credentials/v1", 44 | "https://w3id.org/blockcerts/v3", 45 | ], 46 | type: ["VerifiableCredential"], 47 | } 48 | 49 | const generateVC = (member, date = null) => { 50 | const metadataBody = { 51 | title: member.credential_name, 52 | description: getTokenDescription(), 53 | descriptionJp: getTokenJapaneseDescription(), 54 | } 55 | const vc = Object.assign({}, TEMPLATE_VC) 56 | vc.id = `${UUID_PREFIX}${member.id}` 57 | vc.issuer = issuerDID 58 | vc.issuanceDate = generateIssuanceDate(ISSUANCE_DATE) 59 | vc.metadata = JSON.stringify(metadataBody) 60 | vc.display = { 61 | contentMediaType: "image/svg+xml", 62 | contentEncoding: "base64", 63 | content: fs.readFileSync(member.vc_image_path).toString("base64"), 64 | } 65 | vc.nonce = uuidv4() 66 | vc.credentialSubject = { 67 | id: `did:ethr:${member.holder_wallet_address}`, 68 | } 69 | return vc 70 | } 71 | 72 | const generateIssuanceDate = (date = null) => { 73 | date = date || new Date() 74 | return date.toISOString().split("Z")[0].split(".")[0] + "Z" 75 | } 76 | 77 | const main = async () => { 78 | if (process.argv.length !== 3) { 79 | console.log("[ERROR] input file and output dir must be specified.") 80 | return 81 | } 82 | 83 | if (!issuerDID) { 84 | console.log("[ERROR] ENV ISSUER_DID is not set.") 85 | return 86 | } 87 | 88 | const membersJsonPath = process.argv[2] 89 | const members = JSON.parse(fs.readFileSync(membersJsonPath, "utf8")) 90 | 91 | await cleanupOutputDir(OUPUT_UNSIGNED_VC_DIR, ".json") 92 | await cleanupOutputDir(OUPUT_SIGNED_VC_DIR, ".json") 93 | 94 | let cnt = 0 95 | const total = members.length 96 | const result = [] 97 | for (const member of members) { 98 | cnt += 1 99 | const vc = generateVC(member) 100 | const fileName = `${member.id}.json` 101 | const filePath = path.join(OUPUT_UNSIGNED_VC_DIR, fileName) 102 | console.log(`[${cnt}/${total}] saved ${filePath}`) 103 | fs.writeFileSync(filePath, JSON.stringify(vc)) 104 | result.push(filePath) 105 | member.vc_path = path.join(OUPUT_SIGNED_VC_DIR, fileName) 106 | } 107 | // update members.json to add expected signed VC's paths 108 | fs.writeFileSync(membersJsonPath, JSON.stringify(members)) 109 | return result 110 | } 111 | 112 | main() 113 | .then((_) => { 114 | process.exit(0) 115 | }) 116 | .catch((error) => { 117 | console.error(error) 118 | process.exit(1) 119 | }) 120 | -------------------------------------------------------------------------------- /scripts/generate-vc-image.js: -------------------------------------------------------------------------------- 1 | // 2 | // generate VC images from templates/vc-xxx.svg and members.json 3 | // 4 | // How to use: 5 | // $ node scripts/generate-vc-image.js 6 | // 7 | // e.g. 8 | // $ node scripts/generate-vc-image.js ./tmp/members.json 9 | 10 | const fs = require("fs") 11 | const path = require("path") 12 | 13 | const { 14 | getIssuanceDateFormat, 15 | cleanupOutputDir, 16 | isProduction, 17 | } = require("./utils") 18 | 19 | const OUTPUT_IMAGE_DIR = path.join(__dirname, "../output/vc_images") 20 | const ISSUANCE_DATE_FORMAT = getIssuanceDateFormat() 21 | 22 | const getTemplateFileName = (credentialId) => { 23 | switch (credentialId) { 24 | // credentialIdによって、テンプレート変更する場合に条件追加 25 | default: 26 | return "vc-test.svg" 27 | } 28 | } 29 | 30 | const mLength = (str) => { 31 | let len = 0 32 | for (let i = 0; i < str.length; i++) { 33 | str[i].match(/[ -~]/) ? (len += 1) : (len += 2) 34 | } 35 | return len 36 | } 37 | 38 | // 直線 "y = ax + b" で最適なfontSizeを導く関数 39 | const getHolderNameFontSize = (holderName) => { 40 | const MAX_FONT_SIZE = 1 41 | 42 | // 直線を引く座標 2点 43 | const coordinate1 = { fontSize: 1, length: 9 } // デザイン納品時のfontSizeのまま入る最大文字列長 44 | const coordinate2 = { fontSize: 0.45, length: 29 } // 最大文字列長データに合わせて決めたfontSize 45 | 46 | // 傾き (a) 47 | const slope = 48 | (coordinate2.fontSize - coordinate1.fontSize) / 49 | (coordinate2.length - coordinate1.length) 50 | // 切片 (b) 51 | const yIntercept = coordinate1.fontSize - slope * coordinate1.length 52 | 53 | const fontSize = (slope * mLength(holderName) + yIntercept).toFixed(2) 54 | return fontSize > MAX_FONT_SIZE ? MAX_FONT_SIZE : fontSize 55 | } 56 | 57 | const generate = async (member, notoFontData) => { 58 | const outputFile = path.join(OUTPUT_IMAGE_DIR, `${member.id}.svg`) 59 | 60 | // read template svg 61 | const templateFileName = getTemplateFileName(member.credential_id) 62 | const templatePath = path.join( 63 | __dirname, 64 | `./../templates/${templateFileName}` 65 | ) 66 | const template = fs.readFileSync(templatePath, "utf8") 67 | 68 | // replace each placeholder with member's info 69 | const svg = template 70 | .replaceAll("{{CREDENTIAL_NAME}}", member.credential_name) 71 | .replaceAll("{{HOLDER_NAME}}", member.holder_name) 72 | .replaceAll( 73 | "{{HOLDER_NAME_FONT_SIZE}}", 74 | `${getHolderNameFontSize(member.holder_name)}px` 75 | ) 76 | .replaceAll("{{ISSUANCE_DATE}}", ISSUANCE_DATE_FORMAT) 77 | 78 | fs.writeFileSync(outputFile, svg) 79 | return outputFile 80 | } 81 | 82 | const main = async () => { 83 | if (process.argv.length !== 3) { 84 | console.log("[ERROR] input file and output dir must be specified.") 85 | return 86 | } 87 | 88 | const membersJsonPath = process.argv[2] 89 | const members = JSON.parse(fs.readFileSync(membersJsonPath, "utf8")) 90 | const notoFontData = fs.readFileSync( 91 | path.join(__dirname, `./../templates/fonts/NotoSansJP-Light.base64.txt`) 92 | ) 93 | await cleanupOutputDir(OUTPUT_IMAGE_DIR, ".svg") 94 | 95 | let cnt = 0 96 | const total = members.length 97 | for (const member of members) { 98 | cnt += 1 99 | console.log(`[${cnt}/${total}] Generating a VC image: ${member.id}`) 100 | const imagePath = await generate(member, notoFontData) 101 | member.vc_image_path = imagePath 102 | } 103 | 104 | // update members.json to add the generated image paths 105 | fs.writeFileSync(membersJsonPath, JSON.stringify(members)) 106 | } 107 | 108 | main() 109 | .then(() => process.exit(0)) 110 | .catch((error) => { 111 | console.error(error) 112 | process.exit(1) 113 | }) 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nft-vc 2 | 3 | ## Setup 4 | 5 | - 検証時の環境 6 | - node: 16.13.0 7 | - Python: 3.9.13 8 | 9 | ### node.js 10 | 11 | ```sh 12 | $ yarn 13 | ``` 14 | 15 | ### Python (cert-issuer を利用可能にするため) 16 | 17 | ```sh 18 | $ pyenv local 3.9.13 19 | $ python -m venv venv 20 | $ source venv/bin/activate 21 | $ pip install --upgrade pip 22 | $ pip install -r requirements.txt 23 | 24 | # openssl 関連エラー発生時は以下で解決できたケースあり 25 | $ env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" pip --no-cache-dir install -r requirements.txt 26 | 27 | $ cert-issuer -h 28 | ``` 29 | 30 | ## Issuer Setup 31 | 32 | 1. issuer の情報を did:web メソッドで resolve できるようにする。 33 | 34 | - Ethereum の秘密鍵を指定して JWK 形式の公開鍵に変換する 35 | 36 | ```sh 37 | $ node scripts/convert-did-public-key.js ./keys/wallet-private.dev.key 38 | {"kty":"EC","crv":"K-256","x":"XyZpmS5rwy23Dqm3iYNGn_A_p5JYXOSbyp_ev1Uss7E","y":"XyZvZO81bLTeH0-XmjQlrhAWOC79utg3aC5BV5amAsI"} 39 | ``` 40 | 41 | - `./keys/wallet-private.dev.key` に秘密鍵を配置して下さい 42 | 43 | 2. did.json を作成する (上記の公開鍵情報を含める) 44 | 45 | - 参考: `hostings/staging/public/.well-known/did.json` 46 | 47 | 3. IssuerProfile ファイルを作成する 48 | 49 | - 参考: `hostings/staging/public/blockcerts.json` 50 | 51 | 4. RevocationList ファイルを作成する 52 | 53 | - 参考: `hostings/staging/public/blockcerts_revocation_list.json` 54 | 55 | 5. 手順 2,3,4 で作成したファイルをホスティングする。 56 | 57 | - 参考: `hostings/staging/README.md` 58 | 59 | ## 証明書発行ワークフロー 60 | 61 | ### VC 発行フロー 62 | 63 | 1. GoogleForm から JSON に変換 64 | 65 | ```sh 66 | $ node scripts/convert-members.js ./tmp/form.csv > ./tmp/members.json 67 | ``` 68 | 69 | 2. VC 用の画像生成 70 | 71 | ```sh 72 | $ node scripts/generate-vc-image.js ./tmp/members.json 73 | ``` 74 | 75 | 3. 署名なし VC を生成 76 | 77 | ```sh 78 | $ node scripts/generate-unsigned-vc.js ./tmp/members.json 79 | ``` 80 | 81 | 4. VC 発行 82 | 83 | ```sh 84 | # dev (goerli) 85 | $ cert-issuer -c cert-issuer.dev.ini --chain ethereum_goerli --goerli_rpc_url $GOERLI_ALCHEMY_URL 86 | 87 | # prd (mainnet) 88 | $ cert-issuer -c cert-issuer.prd.ini --chain ethereum_mainnet --ethereum_rpc_url $MAINNET_ALCHEMY_URL 89 | ``` 90 | 91 | 5. 発行済み VC を IPFS にアップロード 92 | 93 | ```sh 94 | $ node scripts/bulk-upload-to-ipfs.js ./tmp/members.json vc 95 | ``` 96 | 97 | ### NFT 発行フロー 98 | 99 | 1. NFT 用の画像生成 100 | 101 | ```sh 102 | $ node scripts/generate-nft-image.js ./tmp/members.json 103 | ``` 104 | 105 | 2. NFT 用の画像を IPFS にアップロード 106 | 107 | ```sh 108 | $ node scripts/bulk-upload-to-ipfs.js ./tmp/members.json nft 109 | ``` 110 | 111 | 3. コントラクトのデプロイ 112 | 113 | ```sh 114 | $ npx hardhat run scripts/deploy.js --network $NODE_ENV 115 | Compiled 1 Solidity file successfully 116 | deployed to: 0x1234567890123456789012345678901234567890 117 | ``` 118 | 119 | 環境変数 `CONTRACT_ADDRESS` に上記のアドレスをセットします。 120 | 121 | 4. ソースコードのアップロード 122 | 123 | ```sh 124 | $ npx hardhat verify $CONTRACT_ADDRESS --constructor-args arguments.js --network $NODE_ENV 125 | ``` 126 | 127 | ※ エラー発生時に `$ npx hardhat clean` で解消するケースを確認している。 128 | ※ エラー発生時でも verify に成功しているケースを確認しているため、etherscan で CONTRACT_ADDRESS を検索するとヒント見つかるかも。 129 | 130 | 5. NFT bulk mint 131 | 132 | ```sh 133 | # dry-run 134 | $ node scripts/bulk-mint.js ./tmp/members.json 135 | 136 | # mint 137 | $ node scripts/bulk-mint.js ./tmp/members.json --dry-run=false 138 | ``` 139 | 140 | ## Test 141 | 142 | ### Test for scripts 143 | 144 | ```sh 145 | $ npm test 146 | ``` 147 | 148 | ※ 一部の Utility 関数のみテスト定義している 149 | 150 | ### Test for Smart Contract 151 | 152 | local でノードを起動 153 | 154 | ```sh 155 | $ npx hardhat node 156 | ``` 157 | 158 | テスト実行 159 | 160 | ```sh 161 | $ npm run test_contracts 162 | # OR 163 | $ npx hardhat test --network localhost 164 | ``` 165 | 166 | テストネット環境でテスト実行 167 | 168 | ```sh 169 | $ npx hardhat test --network goerli 170 | ``` 171 | 172 | ※ ガス代結構かかるので注意。 173 | ※ 一部ケースは NG になる `ethers.getSigners()` で 3 アカウント分のセットアップが必要なため。 174 | -------------------------------------------------------------------------------- /templates/fonts/OFL.txt: -------------------------------------------------------------------------------- 1 | Copyright 2012 Google Inc. All Rights Reserved. 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | This license is copied below, and is also available with a FAQ at: 5 | http://scripts.sil.org/OFL 6 | 7 | 8 | ----------------------------------------------------------- 9 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 | ----------------------------------------------------------- 11 | 12 | PREAMBLE 13 | The goals of the Open Font License (OFL) are to stimulate worldwide 14 | development of collaborative font projects, to support the font creation 15 | efforts of academic and linguistic communities, and to provide a free and 16 | open framework in which fonts may be shared and improved in partnership 17 | with others. 18 | 19 | The OFL allows the licensed fonts to be used, studied, modified and 20 | redistributed freely as long as they are not sold by themselves. The 21 | fonts, including any derivative works, can be bundled, embedded, 22 | redistributed and/or sold with any software provided that any reserved 23 | names are not used by derivative works. The fonts and derivatives, 24 | however, cannot be released under any other type of license. The 25 | requirement for fonts to remain under this license does not apply 26 | to any document created using the fonts or their derivatives. 27 | 28 | DEFINITIONS 29 | "Font Software" refers to the set of files released by the Copyright 30 | Holder(s) under this license and clearly marked as such. This may 31 | include source files, build scripts and documentation. 32 | 33 | "Reserved Font Name" refers to any names specified as such after the 34 | copyright statement(s). 35 | 36 | "Original Version" refers to the collection of Font Software components as 37 | distributed by the Copyright Holder(s). 38 | 39 | "Modified Version" refers to any derivative made by adding to, deleting, 40 | or substituting -- in part or in whole -- any of the components of the 41 | Original Version, by changing formats or by porting the Font Software to a 42 | new environment. 43 | 44 | "Author" refers to any designer, engineer, programmer, technical 45 | writer or other person who contributed to the Font Software. 46 | 47 | PERMISSION & CONDITIONS 48 | Permission is hereby granted, free of charge, to any person obtaining 49 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 | redistribute, and sell modified and unmodified copies of the Font 51 | Software, subject to the following conditions: 52 | 53 | 1) Neither the Font Software nor any of its individual components, 54 | in Original or Modified Versions, may be sold by itself. 55 | 56 | 2) Original or Modified Versions of the Font Software may be bundled, 57 | redistributed and/or sold with any software, provided that each copy 58 | contains the above copyright notice and this license. These can be 59 | included either as stand-alone text files, human-readable headers or 60 | in the appropriate machine-readable metadata fields within text or 61 | binary files as long as those fields can be easily viewed by the user. 62 | 63 | 3) No Modified Version of the Font Software may use the Reserved Font 64 | Name(s) unless explicit written permission is granted by the corresponding 65 | Copyright Holder. This restriction only applies to the primary font name as 66 | presented to the users. 67 | 68 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 | Software shall not be used to promote, endorse or advertise any 70 | Modified Version, except to acknowledge the contribution(s) of the 71 | Copyright Holder(s) and the Author(s) or with their explicit written 72 | permission. 73 | 74 | 5) The Font Software, modified or unmodified, in part or in whole, 75 | must be distributed entirely under this license, and must not be 76 | distributed under any other license. The requirement for fonts to 77 | remain under this license does not apply to any document created 78 | using the Font Software. 79 | 80 | TERMINATION 81 | This license becomes null and void if any of the above conditions are 82 | not met. 83 | 84 | DISCLAIMER 85 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 | OTHER DEALINGS IN THE FONT SOFTWARE. 94 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | .python-version 3 | 4 | tmp/* 5 | !tmp/.keep 6 | 7 | keys/* 8 | !keys/.keep 9 | !keys/wallet-private.dev.key 10 | 11 | output/blockchain_certificates/* 12 | !output/blockchain_certificates/.keep 13 | output/unsigned_certificates/* 14 | !output/unsigned_certificates/.keep 15 | output/nft_images/* 16 | !output/nft_images/.keep 17 | output/vc_images/* 18 | !output/vc_images/.keep 19 | 20 | #Hardhat files 21 | cache 22 | artifacts 23 | 24 | # Created by https://www.toptal.com/developers/gitignore/api/linux,macos,windows,visualstudiocode,node 25 | # Edit at https://www.toptal.com/developers/gitignore?templates=linux,macos,windows,visualstudiocode,node 26 | 27 | ### Linux ### 28 | *~ 29 | 30 | # temporary files which can be created if a process still has a handle open of a deleted file 31 | .fuse_hidden* 32 | 33 | # KDE directory preferences 34 | .directory 35 | 36 | # Linux trash folder which might appear on any partition or disk 37 | .Trash-* 38 | 39 | # .nfs files are created when an open file is removed but is still being accessed 40 | .nfs* 41 | 42 | ### macOS ### 43 | # General 44 | .DS_Store 45 | .AppleDouble 46 | .LSOverride 47 | 48 | # Icon must end with two \r 49 | Icon 50 | 51 | 52 | # Thumbnails 53 | ._* 54 | 55 | # Files that might appear in the root of a volume 56 | .DocumentRevisions-V100 57 | .fseventsd 58 | .Spotlight-V100 59 | .TemporaryItems 60 | .Trashes 61 | .VolumeIcon.icns 62 | .com.apple.timemachine.donotpresent 63 | 64 | # Directories potentially created on remote AFP share 65 | .AppleDB 66 | .AppleDesktop 67 | Network Trash Folder 68 | Temporary Items 69 | .apdisk 70 | 71 | ### macOS Patch ### 72 | # iCloud generated files 73 | *.icloud 74 | 75 | ### Node ### 76 | # Logs 77 | logs 78 | *.log 79 | npm-debug.log* 80 | yarn-debug.log* 81 | yarn-error.log* 82 | lerna-debug.log* 83 | .pnpm-debug.log* 84 | 85 | # Diagnostic reports (https://nodejs.org/api/report.html) 86 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 87 | 88 | # Runtime data 89 | pids 90 | *.pid 91 | *.seed 92 | *.pid.lock 93 | 94 | # Directory for instrumented libs generated by jscoverage/JSCover 95 | lib-cov 96 | 97 | # Coverage directory used by tools like istanbul 98 | coverage 99 | *.lcov 100 | 101 | # nyc test coverage 102 | .nyc_output 103 | 104 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 105 | .grunt 106 | 107 | # Bower dependency directory (https://bower.io/) 108 | bower_components 109 | 110 | # node-waf configuration 111 | .lock-wscript 112 | 113 | # Compiled binary addons (https://nodejs.org/api/addons.html) 114 | build/Release 115 | 116 | # Dependency directories 117 | node_modules/ 118 | jspm_packages/ 119 | 120 | # Snowpack dependency directory (https://snowpack.dev/) 121 | web_modules/ 122 | 123 | # TypeScript cache 124 | *.tsbuildinfo 125 | 126 | # Optional npm cache directory 127 | .npm 128 | 129 | # Optional eslint cache 130 | .eslintcache 131 | 132 | # Optional stylelint cache 133 | .stylelintcache 134 | 135 | # Microbundle cache 136 | .rpt2_cache/ 137 | .rts2_cache_cjs/ 138 | .rts2_cache_es/ 139 | .rts2_cache_umd/ 140 | 141 | # Optional REPL history 142 | .node_repl_history 143 | 144 | # Output of 'npm pack' 145 | *.tgz 146 | 147 | # Yarn Integrity file 148 | .yarn-integrity 149 | 150 | # dotenv environment variable files 151 | .env 152 | .env.development.local 153 | .env.test.local 154 | .env.production.local 155 | .env.local 156 | 157 | # parcel-bundler cache (https://parceljs.org/) 158 | .cache 159 | .parcel-cache 160 | 161 | # Next.js build output 162 | .next 163 | out 164 | 165 | # Nuxt.js build / generate output 166 | .nuxt 167 | dist 168 | 169 | # Gatsby files 170 | .cache/ 171 | # Comment in the public line in if your project uses Gatsby and not Next.js 172 | # https://nextjs.org/blog/next-9-1#public-directory-support 173 | # public 174 | 175 | # vuepress build output 176 | .vuepress/dist 177 | 178 | # vuepress v2.x temp and cache directory 179 | .temp 180 | 181 | # Docusaurus cache and generated files 182 | .docusaurus 183 | 184 | # Serverless directories 185 | .serverless/ 186 | 187 | # FuseBox cache 188 | .fusebox/ 189 | 190 | # DynamoDB Local files 191 | .dynamodb/ 192 | 193 | # TernJS port file 194 | .tern-port 195 | 196 | # Stores VSCode versions used for testing VSCode extensions 197 | .vscode-test 198 | 199 | # yarn v2 200 | .yarn/cache 201 | .yarn/unplugged 202 | .yarn/build-state.yml 203 | .yarn/install-state.gz 204 | .pnp.* 205 | 206 | ### Node Patch ### 207 | # Serverless Webpack directories 208 | .webpack/ 209 | 210 | # Optional stylelint cache 211 | 212 | # SvelteKit build / generate output 213 | .svelte-kit 214 | 215 | ### VisualStudioCode ### 216 | .vscode/* 217 | !.vscode/settings.json 218 | !.vscode/tasks.json 219 | !.vscode/launch.json 220 | !.vscode/extensions.json 221 | !.vscode/*.code-snippets 222 | 223 | # Local History for Visual Studio Code 224 | .history/ 225 | 226 | # Built Visual Studio Code Extensions 227 | *.vsix 228 | 229 | ### VisualStudioCode Patch ### 230 | # Ignore all local history of files 231 | .history 232 | .ionide 233 | 234 | # Support for Project snippet scope 235 | .vscode/*.code-snippets 236 | 237 | # Ignore code-workspaces 238 | *.code-workspace 239 | 240 | ### Windows ### 241 | # Windows thumbnail cache files 242 | Thumbs.db 243 | Thumbs.db:encryptable 244 | ehthumbs.db 245 | ehthumbs_vista.db 246 | 247 | # Dump file 248 | *.stackdump 249 | 250 | # Folder config file 251 | [Dd]esktop.ini 252 | 253 | # Recycle Bin used on file shares 254 | $RECYCLE.BIN/ 255 | 256 | # Windows Installer files 257 | *.cab 258 | *.msi 259 | *.msix 260 | *.msm 261 | *.msp 262 | 263 | # Windows shortcuts 264 | *.lnk 265 | 266 | # End of https://www.toptal.com/developers/gitignore/api/linux,macos,windows,visualstudiocode,node -------------------------------------------------------------------------------- /templates/fonts/NotoSansJP-Light.base64.txt: -------------------------------------------------------------------------------- 1 | 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 -------------------------------------------------------------------------------- /scripts/bulk-mint.js: -------------------------------------------------------------------------------- 1 | // 2 | // bulk mint from members.json 3 | // 4 | // (DRY RUN) 5 | // node scripts/bulk-mint.js ./tmp/members.json 6 | // (本実行) 7 | // node scripts/bulk-mint.js ./tmp/members.json --dry-run=false 8 | // 9 | 10 | const { NODE_ENV, CONTRACT_ADDRESS, PUBLIC_KEY, PRIVATE_KEY } = process.env 11 | 12 | const fs = require("fs") 13 | const { createAlchemyWeb3 } = require("@alch/alchemy-web3") 14 | 15 | const { 16 | getAlchemyUrl, 17 | fetchGasFee, 18 | getOpenSeaDescription, 19 | getTokenSymbolFromEnv, 20 | getIpfsUrl, 21 | getVerifyUrl, 22 | logWithTime, 23 | createMintBatches, 24 | } = require("./utils") 25 | 26 | const web3 = createAlchemyWeb3(getAlchemyUrl()) 27 | const contract = require("../artifacts/contracts/NFTCredential.sol/NFTCredential.json") 28 | const nftContract = new web3.eth.Contract(contract.abi, CONTRACT_ADDRESS) 29 | 30 | const TOKEN_SYMBOL = getTokenSymbolFromEnv() 31 | const TOKEN_DESCRIPTION = getOpenSeaDescription() 32 | const ARGV_ERR_MSG = `[ARGV ERROR] The argv are something wrong.\nExpected cmd is below:\n \$ node scripts/bulk-mint.js (--dry-run=true|false)` 33 | 34 | function toHexFromGwei(gwei) { 35 | return web3.utils.toHex(web3.utils.toWei(gwei.toFixed(9), "gwei")) 36 | } 37 | 38 | function toEtherFromGasAndFee(gas, feeGwei) { 39 | return web3.utils.fromWei( 40 | web3.utils.toWei((gas * feeGwei).toFixed(9), "gwei") 41 | ) 42 | } 43 | 44 | /** 45 | * execute Contract#mintAndTrasfer 46 | */ 47 | async function mintAndTrasfer(batchMembers, dryrun = true, logPrefix = null) { 48 | let estimatedGas = 0 49 | let maxFee = 0 50 | try { 51 | const nonce = await web3.eth.getTransactionCount(PUBLIC_KEY, "latest") 52 | 53 | // setup the args of Contract.mintAndTransfer 54 | const credentialId = batchMembers[0].credential_id 55 | const addresses = batchMembers.map((member) => member.holder_wallet_address) 56 | const imageURIs = batchMembers.map((member) => 57 | getIpfsUrl(member.nft_image_ipfs_hash) 58 | ) 59 | const externalURIs = batchMembers.map((member) => 60 | getVerifyUrl(member.vc_ipfs_hash) 61 | ) 62 | const txData = nftContract.methods 63 | .mintAndTransfer( 64 | credentialId, 65 | TOKEN_DESCRIPTION, 66 | addresses, 67 | imageURIs, 68 | externalURIs 69 | ) 70 | .encodeABI() 71 | 72 | const estimatedGas = await web3.eth.estimateGas({ 73 | from: PUBLIC_KEY, 74 | to: CONTRACT_ADDRESS, 75 | nonce, 76 | data: txData, 77 | }) 78 | const gasFee = await fetchGasFee() 79 | maxFee = gasFee.maxFee 80 | const maxPriorityFee = gasFee.maxPriorityFee 81 | 82 | const signedTx = await web3.eth.accounts.signTransaction( 83 | { 84 | from: PUBLIC_KEY, 85 | to: CONTRACT_ADDRESS, 86 | nonce, 87 | data: txData, 88 | gas: estimatedGas, 89 | maxFeePerGas: toHexFromGwei(maxFee), 90 | maxPriorityFeePerGas: toHexFromGwei(maxPriorityFee), 91 | }, 92 | PRIVATE_KEY 93 | ) 94 | logWithTime( 95 | `trying to send transaction: ${signedTx.transactionHash}`, 96 | logPrefix 97 | ) 98 | logWithTime( 99 | `nonce=${nonce}, estimateGas=${estimatedGas}(Gas), maxPriorityFeePerGas=${maxPriorityFee}(Gwei), maxFeePerGas=${maxFee}(Gwei)`, 100 | logPrefix 101 | ) 102 | let transactionReceipt 103 | if (dryrun) { 104 | transactionReceipt = { transactionHash: "DRY_RUN_MODE" } 105 | } else { 106 | transactionReceipt = await web3.eth.sendSignedTransaction( 107 | signedTx.rawTransaction 108 | ) 109 | } 110 | return [true, transactionReceipt, estimatedGas, maxFee] 111 | } catch (e) { 112 | return [false, e, estimatedGas, maxFee] 113 | } 114 | } 115 | 116 | async function main() { 117 | let dryrun = true 118 | switch (process.argv.length) { 119 | case 3: 120 | break 121 | case 4: 122 | option = process.argv[3] 123 | if (!["--dry-run=true", "--dry-run=false"].includes(option)) { 124 | throw ARGV_ERR_MSG 125 | } else if (option === "--dry-run=false") { 126 | dryrun = false 127 | } 128 | break 129 | default: 130 | throw ARGV_ERR_MSG 131 | } 132 | logWithTime( 133 | `START dry-run=${dryrun}, NODE_ENV=${NODE_ENV}, CONTRACT=${CONTRACT_ADDRESS}` 134 | ) 135 | 136 | const ok = [] 137 | const ng = [] 138 | let totalGasEstimated = 0 139 | let totalGwei = 0 140 | let totalGasUsed = 0 141 | const beforeBalanceWei = await web3.eth.getBalance(PUBLIC_KEY) 142 | 143 | const members = JSON.parse(fs.readFileSync(process.argv[2], "utf8")) 144 | const mintBatches = createMintBatches({ members }) 145 | const totalBatch = mintBatches.length 146 | let batchIndex = 0 147 | 148 | console.log(`\n----- Log -----`) 149 | for (const batchMembers of mintBatches) { 150 | logPrefix = `${("000" + (batchIndex + 1)).slice(-3)}/${( 151 | "000" + totalBatch 152 | ).slice(-3)}` 153 | const [isSucceeded, result, gasEstimated, maxFeePerGas] = 154 | await mintAndTrasfer(batchMembers, dryrun, logPrefix) 155 | 156 | let gasUsed = 0 157 | if (isSucceeded) { 158 | if (result.gasUsed) { 159 | gasUsed = result.gasUsed 160 | } 161 | logWithTime( 162 | `[SUCCESS] batchIndex=${batchIndex}, gasUsed=${gasUsed}(Gas), estimatedCost=${toEtherFromGasAndFee( 163 | gasEstimated, 164 | maxFeePerGas 165 | )}(${TOKEN_SYMBOL})`, 166 | logPrefix 167 | ) 168 | ok.push(batchIndex) 169 | } else { 170 | if (result.receipt && result.receipt.gasUsed) { 171 | gasUsed = result.receipt.gasUsed 172 | } 173 | logWithTime( 174 | `[ERROR] batchIndex=${batchIndex}, gasUsed=${gasUsed}(Gas), estimatedCost=${toEtherFromGasAndFee( 175 | gasEstimated, 176 | maxFeePerGas 177 | )}(${TOKEN_SYMBOL})`, 178 | logPrefix 179 | ) 180 | console.error(result) 181 | ng.push(batchIndex) 182 | } 183 | 184 | totalGasEstimated += gasEstimated 185 | totalGwei += gasEstimated * maxFeePerGas 186 | totalGasUsed += gasUsed 187 | batchIndex += 1 188 | } 189 | 190 | if (dryrun) { 191 | const totalWei = web3.utils.toWei(totalGwei.toFixed(9), "gwei") 192 | console.log(`\n----- Check Balance -----`) 193 | console.log( 194 | `Is your balance enough?: ${BigInt(beforeBalanceWei) > BigInt(totalWei)}` 195 | ) 196 | console.log( 197 | `Before Balance : ${web3.utils.fromWei( 198 | beforeBalanceWei 199 | )}(${TOKEN_SYMBOL})` 200 | ) 201 | console.log( 202 | `Estimated Ether: ${toEtherFromGasAndFee(totalGwei, 1)}(${TOKEN_SYMBOL})` 203 | ) 204 | console.log(`GasEstimated : ${totalGasEstimated}(Gas)`) 205 | } 206 | 207 | return [ok, ng, totalGasUsed, totalGasEstimated] 208 | } 209 | 210 | main() 211 | .then((result) => { 212 | const [ok, ng, gasUsed, estimatedGas] = result 213 | console.log(`\n----- Results -----`) 214 | console.log(`TOTAL: ${ok.length + ng.length}(Batches)`) 215 | console.log(`OK : ${ok.length}(Batches)`) 216 | console.log(`NG : ${ng.length}(Batches)`) 217 | console.log(`GasUsed : ${gasUsed}(Gas)`) 218 | console.log(`EstimatedGas: ${estimatedGas}(Gas)`) 219 | 220 | console.log(`\n----- Effected Wallet Addresses -----`) 221 | console.log(`OK Batch Indexes(${ok.length}): ${JSON.stringify(ok)}`) 222 | console.log(`NG Batch Indexes(${ng.length}): ${JSON.stringify(ng)}`) 223 | console.log() 224 | logWithTime("END") 225 | 226 | process.exit(0) 227 | }) 228 | .catch((e) => { 229 | console.error(e) 230 | process.exit(1) 231 | }) 232 | -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | // 2 | // Constants and utility module 3 | // 4 | 5 | const fs = require("fs") 6 | const fetch = require("node-fetch") 7 | const path = require("path") 8 | 9 | const { 10 | NODE_ENV, 11 | MAINNET_ALCHEMY_URL, 12 | RINKEBY_ALCHEMY_URL, 13 | GOERLI_ALCHEMY_URL, 14 | POLYGON_ALCHEMY_URL, 15 | MUMBAI_ALCHEMY_URL, 16 | PINATA_PRODUCTION_IPFS_URL_PREFIX, 17 | PINATA_DEVELOPMENT_IPFS_URL_PREFIX, 18 | ETHERSCAN_API_TOKEN, 19 | VERIFY_HOST_URL, 20 | VERIFY_HOST_URL_DEV 21 | } = process.env 22 | 23 | // ============================================================= 24 | // Constants or like constants 25 | // ============================================================= 26 | 27 | const ISSUANCE_DATE = new Date("2022-09-29T00:00:00Z") 28 | 29 | // max batch size when bulk mint and transfer 30 | const MAX_BATCH_SIZE = 5 31 | 32 | const isProduction = () => { 33 | return NODE_ENV === "mainnet" || NODE_ENV === "production" || NODE_ENV === "polygon" 34 | } 35 | 36 | const getAlchemyUrl = () => { 37 | switch (NODE_ENV) { 38 | case "production": 39 | case "mainnet": 40 | return MAINNET_ALCHEMY_URL 41 | case "rinkeby": 42 | return RINKEBY_ALCHEMY_URL 43 | case "goerli": 44 | return GOERLI_ALCHEMY_URL 45 | case "polygon": 46 | return POLYGON_ALCHEMY_URL 47 | case "polygonMumbai": 48 | return MUMBAI_ALCHEMY_URL 49 | default: 50 | return RINKEBY_ALCHEMY_URL 51 | } 52 | } 53 | 54 | const getGasStationUrl = () => { 55 | const ethMainnetEndpoint = `https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=${ETHERSCAN_API_TOKEN}` 56 | switch (NODE_ENV) { 57 | case "production": 58 | case "mainnet": 59 | return ethMainnetEndpoint 60 | case "rinkeby": 61 | // There is no gas tracker support on this testnet. 62 | // refs: https://docs.etherscan.io/v/rinkeby-etherscan 63 | return ethMainnetEndpoint 64 | case "goerli": 65 | // There is no gas tracker support on this testnet. 66 | // refs: https://docs.etherscan.io/v/goerli-etherscan/ 67 | return ethMainnetEndpoint 68 | case "polygon": 69 | return "https://gasstation-mainnet.matic.network/v2" 70 | case "polygonMumbai": 71 | return "https://gasstation-mumbai.matic.today/v2" 72 | default: 73 | return ethMainnetEndpoint 74 | } 75 | } 76 | 77 | const fetchGasFee = async () => { 78 | const res = await (await fetch(getGasStationUrl())).json() 79 | 80 | // case of Polygon chain: 81 | switch (NODE_ENV) { 82 | case "polygon": 83 | case "polygonMumbai": 84 | return { 85 | maxPriorityFee: res.fast.maxPriorityFee, 86 | maxFee: res.fast.maxFee, 87 | } 88 | } 89 | 90 | // case of Ethereum chain: 91 | return { 92 | maxPriorityFee: parseFloat(res.result.ProposeGasPrice), 93 | maxFee: parseFloat(res.result.FastGasPrice), 94 | } 95 | } 96 | 97 | const getTokenSymbolFromEnv = () => { 98 | switch (NODE_ENV) { 99 | case "polygon": 100 | case "polygonMumbai": 101 | return "MATIC" 102 | } 103 | return "ETH" 104 | } 105 | 106 | const getTokenName = () => { 107 | return "XXX Credentials v1.0" 108 | } 109 | 110 | const getTokenSymbol = () => { 111 | return "XXX Credentials" 112 | } 113 | 114 | const getTokenDescription = () => { 115 | return "XXX Credentials is a platform for NFT delivery of certificates of completion and diplomas for special courses offered by XXXXX XXXXXXXXX of XXXXXXXXXX. It is distributed to those who have passed the course or graduated from the undergraduate or graduate program." 116 | } 117 | 118 | const getTokenJapaneseDescription = () => { 119 | return "XXX Credentialsは、XXXXXXの開講する特別な講座、コースに対する受講修了証明書、卒業の証となる学位記などをNFTで配信するプラットフォームです。講座の合格者や本学の学部卒業生・大学院修了生に対して配信されます。" 120 | } 121 | 122 | const getOpenSeaDescription = () => { 123 | return `${getTokenDescription()} (IN JAPANESE) ${getTokenJapaneseDescription()}` 124 | } 125 | 126 | const getIpfsUrl = (ipfsHash) => { 127 | let baseUrl = isProduction() 128 | ? PINATA_PRODUCTION_IPFS_URL_PREFIX 129 | : PINATA_DEVELOPMENT_IPFS_URL_PREFIX 130 | if (!baseUrl) { 131 | baseUrl = "https://gateway.pinata.cloud/ipfs/" 132 | } 133 | return `${baseUrl}${ipfsHash}` 134 | } 135 | 136 | const getVerifyUrl = (ipfsHash) => { 137 | const host = isProduction() 138 | ? VERIFY_HOST_URL 139 | : VERIFY_HOST_URL_DEV 140 | return `${host}/certificate/${ipfsHash}` 141 | } 142 | 143 | // ============================================================= 144 | // Utilitiy functions 145 | // ============================================================= 146 | 147 | function dateFormat(date = new Date(), separator = "/") { 148 | const yyyy = date.getFullYear() 149 | const mm = ("00" + (date.getMonth() + 1)).slice(-2) 150 | const dd = ("00" + date.getDate()).slice(-2) 151 | return [yyyy, mm, dd].join(separator) 152 | } 153 | 154 | function getIssuanceDateFormat() { 155 | return dateFormat(ISSUANCE_DATE) 156 | } 157 | 158 | function logWithTime(msg, logPrefix = null) { 159 | logMsg = logPrefix ? `${logPrefix} ${msg}` : msg 160 | const now = new Date() 161 | console.log( 162 | `[${now.toLocaleString()}.${("000" + now.getMilliseconds()).slice( 163 | -3 164 | )}] ${logMsg}` 165 | ) 166 | } 167 | 168 | const encodeBase64FromImage = (imageFilepath, mimeImageType = "png") => { 169 | const content = fs.readFileSync(imageFilepath) 170 | return `data:image/${mimeImageType};base64,${content.toString("base64")}` 171 | } 172 | 173 | const cleanupOutputDir = (dirpath, targetExtension = ".json") => { 174 | return new Promise((resolve, reject) => { 175 | fs.readdir(dirpath, (err, files) => { 176 | if (err) { 177 | reject(err) 178 | return 179 | } 180 | 181 | const jsonFiles = files.filter((filename) => { 182 | const filePath = path.join(dirpath, filename) 183 | return ( 184 | fs.statSync(filePath).isFile() && filename.endsWith(targetExtension) 185 | ) 186 | }) 187 | 188 | const deleteFiles = [] 189 | for (const filename of jsonFiles) { 190 | const filePath = path.join(dirpath, filename) 191 | fs.unlinkSync(filePath) 192 | deleteFiles.push(filePath) 193 | } 194 | resolve(deleteFiles) 195 | }) 196 | }) 197 | } 198 | 199 | /** 200 | * create a mint batch set from members 201 | * 202 | * test file: tests/scripts/utils-test.js 203 | */ 204 | function createMintBatches({ 205 | members, 206 | batchSize = MAX_BATCH_SIZE, 207 | groupField = "credential_id", 208 | }) { 209 | /** 210 | * e.g. 211 | * > const array1 = [1,2,3,4,5,6] 212 | * > splitAtEqualNum(array1, 2) 213 | * => [ [ 1, 2 ], [ 3, 4 ], [ 5, 6 ] ] 214 | * 215 | * > splitAtEqualNum(array1, 4) 216 | * => [ [ 1, 2, 3, 4 ], [ 5, 6 ] ] 217 | */ 218 | function splitAtEqualNum([...array], size = 1) { 219 | return array.reduce( 220 | (pre, _current, index) => 221 | index % size ? pre : [...pre, array.slice(index, index + size)], 222 | [] 223 | ) 224 | } 225 | 226 | const itemsGroupByKey = {} 227 | for (const member of members) { 228 | const key = member[groupField] 229 | if (!(key in itemsGroupByKey)) { 230 | itemsGroupByKey[key] = [] 231 | } 232 | itemsGroupByKey[key].push(member) 233 | } 234 | 235 | let batches = [] 236 | for (const key in itemsGroupByKey) { 237 | for (const batch of splitAtEqualNum(itemsGroupByKey[key], batchSize)) { 238 | batches.push(batch) 239 | } 240 | } 241 | return batches 242 | } 243 | 244 | module.exports = { 245 | ISSUANCE_DATE, 246 | MAX_BATCH_SIZE, 247 | isProduction, 248 | getAlchemyUrl, 249 | getGasStationUrl, 250 | fetchGasFee, 251 | getTokenSymbolFromEnv, 252 | getTokenName, 253 | getTokenSymbol, 254 | getTokenDescription, 255 | getTokenJapaneseDescription, 256 | getOpenSeaDescription, 257 | getIpfsUrl, 258 | getVerifyUrl, 259 | getIssuanceDateFormat, 260 | logWithTime, 261 | cleanupOutputDir, 262 | encodeBase64FromImage, 263 | createMintBatches, 264 | } 265 | -------------------------------------------------------------------------------- /contracts/NFTCredential.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: Unlicense 2 | pragma solidity ^0.8.0; 3 | 4 | import "hardhat/console.sol"; 5 | import "erc721a/contracts/ERC721A.sol"; 6 | import "@openzeppelin/contracts/access/Ownable.sol"; 7 | import "@openzeppelin/contracts/utils/Base64.sol"; 8 | 9 | contract NFTCredential is ERC721A, Ownable { 10 | // Max batch size for minting one time 11 | uint256 private _maxBatchSize; 12 | 13 | // Mapping from token Id to hashed credential ID 14 | mapping(uint256 => string) private _ownedCredential; 15 | 16 | // Mapping from hashed credential Id to owner possession flag 17 | mapping(string => mapping(address => bool)) private _credentialOwnerships; 18 | 19 | constructor( 20 | string memory _name, 21 | string memory _symbol, 22 | uint256 _newMaxBatchSize 23 | ) ERC721A(_name, _symbol) { 24 | _maxBatchSize = _newMaxBatchSize; 25 | } 26 | 27 | function mintAndTransfer( 28 | string memory _credentialId, 29 | string memory _description, 30 | address[] memory _toAddresses, 31 | string[] memory _imageURIs, 32 | string[] memory _externalURIs 33 | ) public onlyOwner { 34 | uint256 requestNum = _toAddresses.length; 35 | require(requestNum > 0, "The _toAddresses is empty."); 36 | require( 37 | requestNum <= getMaxBatchSize(), 38 | "The length of _toAddresses must be less than or equal to _maxBatchSize." 39 | ); 40 | require( 41 | requestNum == _imageURIs.length, 42 | "The length of _toAddresses and _imageURIs are NOT same." 43 | ); 44 | require( 45 | requestNum == _externalURIs.length, 46 | "The length of _toAddresses and _externalURIs are NOT same." 47 | ); 48 | for (uint i = 0; i < requestNum; i++) { 49 | address _to = _toAddresses[i]; 50 | bool hasCredential = _credentialOwnerships[_credentialId][_to]; 51 | require( 52 | !hasCredential, 53 | "One or more recipient has had already the same credential." 54 | ); 55 | require(_to != owner(), "_toAddresses must NOT be included OWNER."); 56 | } 57 | 58 | // put the next token ID down in the variable before the bulk mint 59 | uint256 startTokenId = _nextTokenId(); 60 | _safeMint(owner(), requestNum); 61 | 62 | // do bulk transfer to each specified address only for the minted tokens 63 | uint256 tokenId = startTokenId; 64 | for (uint i = 0; i < requestNum; i++) { 65 | // update the credential ID mapping 66 | _ownedCredential[tokenId] = _credentialId; 67 | // transfer to the specified address 68 | safeTransferFrom(owner(), _toAddresses[i], tokenId); 69 | // update the token URI 70 | _setTokenURI( 71 | tokenId, 72 | generateTokenURI(_description, _imageURIs[i], _externalURIs[i]) 73 | ); 74 | tokenId += 1; 75 | } 76 | } 77 | 78 | function transferFrom( 79 | address from, 80 | address to, 81 | uint256 tokenId 82 | ) public override onlyOwner { 83 | address _tokenOwner = ownerOf(tokenId); 84 | require( 85 | from == _tokenOwner, 86 | "The from-address is NOT the token ID's owner." 87 | ); 88 | 89 | // Banned for transfering to OWNER, 90 | // because to make sure that the status of credential ID mappings will not be complicated. 91 | require(to != owner(), "The to-address must NOT be OWNER."); 92 | 93 | string memory _credentialId = ownedCredential(tokenId); 94 | bool _hasToAddr = _credentialOwnerships[_credentialId][to]; 95 | require(!_hasToAddr, "The to-address has the same credential already."); 96 | 97 | super.transferFrom(from, to, tokenId); 98 | // update the credential ID mappings 99 | _credentialOwnerships[_credentialId][from] = false; 100 | _credentialOwnerships[_credentialId][to] = true; 101 | } 102 | 103 | function updateNFT( 104 | uint256 tokenId, 105 | string memory _credentialId, 106 | string memory _description, 107 | string memory _imageURI, 108 | string memory _externalURI 109 | ) public onlyOwner { 110 | // update the credential ID mappings 111 | string memory _currentCredentialId = ownedCredential(tokenId); 112 | bytes32 _currentHash = keccak256(bytes(_currentCredentialId)); 113 | bytes32 _newHash = keccak256(bytes(_credentialId)); 114 | if (_currentHash != _newHash) { 115 | _ownedCredential[tokenId] = _credentialId; 116 | address _tokenOwner = ownerOf(tokenId); 117 | _credentialOwnerships[_currentCredentialId][_tokenOwner] = false; 118 | _credentialOwnerships[_credentialId][_tokenOwner] = true; 119 | } 120 | // update the tokenURI 121 | _setTokenURI(tokenId, generateTokenURI(_description, _imageURI, _externalURI)); 122 | } 123 | 124 | function getMaxBatchSize() public view onlyOwner returns (uint256) { 125 | return _maxBatchSize; 126 | } 127 | 128 | function setMaxBatchSize(uint256 _newMaxBatchSize) public onlyOwner { 129 | _maxBatchSize = _newMaxBatchSize; 130 | } 131 | 132 | function ownedCredential(uint256 tokenId) 133 | public 134 | view 135 | onlyOwner 136 | returns (string memory) 137 | { 138 | require(_exists(tokenId), "The token ID does NOT exist."); 139 | return _ownedCredential[tokenId]; 140 | } 141 | 142 | function transferOwnership(address newOwner) public override onlyOwner { 143 | // Banned for transfering ownership to a user who has this token already, 144 | // because to make sure that the status of credential ID mappings will not be complicated. 145 | require(balanceOf(newOwner) == 0, "newOwner's balance must be zero."); 146 | super.transferOwnership(newOwner); 147 | } 148 | 149 | // To be soulbound NFT except owner operations. 150 | function _beforeTokenTransfers( 151 | address, 152 | address, 153 | uint256, 154 | uint256 155 | ) internal view override onlyOwner {} 156 | 157 | function generateTokenURI( 158 | string memory _description, 159 | string memory _imageURI, 160 | string memory _externalURI 161 | ) internal view returns (string memory) { 162 | bytes memory _attributes = abi.encodePacked('"attributes": []'); 163 | string memory json = Base64.encode( 164 | abi.encodePacked( 165 | '{"name": "', 166 | name(), 167 | '",' 168 | '"description": "', 169 | _description, 170 | '",' 171 | '"image": "', 172 | _imageURI, 173 | '",', 174 | '"external_url": "', 175 | _externalURI, 176 | '",', 177 | string(_attributes), 178 | "}" 179 | ) 180 | ); 181 | return string(abi.encodePacked("data:application/json;base64,", json)); 182 | } 183 | 184 | // ============================================================= 185 | // The followings are copied from ERC721URIStorage.sol 186 | // ============================================================= 187 | 188 | // Optional mapping for token URIs 189 | mapping(uint256 => string) private _tokenURIs; 190 | 191 | /** 192 | * @dev See {IERC721Metadata-tokenURI}. 193 | */ 194 | function tokenURI(uint256 tokenId) 195 | public 196 | view 197 | override 198 | returns (string memory) 199 | { 200 | require(_exists(tokenId)); 201 | string memory _tokenURI = _tokenURIs[tokenId]; 202 | string memory base = _baseURI(); 203 | 204 | // If there is no base URI, return the token URI. 205 | if (bytes(base).length == 0) { 206 | return _tokenURI; 207 | } 208 | // If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked). 209 | if (bytes(_tokenURI).length > 0) { 210 | return string(abi.encodePacked(base, _tokenURI)); 211 | } 212 | 213 | return super.tokenURI(tokenId); 214 | } 215 | 216 | /** 217 | * @dev Sets `_tokenURI` as the tokenURI of `tokenId`. 218 | * 219 | * Requirements: 220 | * 221 | * - `tokenId` must exist. 222 | */ 223 | function _setTokenURI(uint256 tokenId, string memory _tokenURI) internal { 224 | require(_exists(tokenId), "URI set of nonexistent token"); 225 | _tokenURIs[tokenId] = _tokenURI; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /tests/contracts/nft-credential-test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai") 2 | const crypto = require("crypto") 3 | const { ethers } = require("hardhat") 4 | const { 5 | MAX_BATCH_SIZE, 6 | getOpenSeaDescription, 7 | } = require("./../../scripts/utils") 8 | 9 | const SUBJECT_CONTRACT_NAME = "NFTCredential" 10 | const DEFAULT_TOKEN_NAME = "TOKEN_NAME1" 11 | const DEFAULT_TOKEN_SYMBOL = "TEST1" 12 | const DEFAULT_MAX_BATCH_SIZE = 2 13 | const DEFAULT_CREDENTIAL_ID = "CRED_001" 14 | const DEFAULT_TOKEN_DESCRIPTION = getOpenSeaDescription() 15 | 16 | // 本番相当データ量でのガス使用量を見積もるケースの実行有無 17 | const ENABLE_LARGE_MINTIING_CASES = false 18 | 19 | describe("NFTCredential", function () { 20 | let contract 21 | let owner, alice, bob 22 | let dummyAddresses 23 | let assertionError 24 | const cred1 = "CRED_201" 25 | const cred2 = "CRED_202" 26 | 27 | beforeEach(async function () { 28 | assertionError = false 29 | contract = await initializeContract({}) 30 | ;[owner, alice, bob] = await ethers.getSigners() 31 | dummyAddresses = generateWalletAddresses() 32 | }) 33 | 34 | describe("constructor", function () { 35 | it(`can set the name and the symbol`, async function () { 36 | expect(await contract.name()).to.be.equal(DEFAULT_TOKEN_NAME) 37 | expect(await contract.symbol()).to.be.equal(DEFAULT_TOKEN_SYMBOL) 38 | expect(await contract.symbol()).to.be.equal(DEFAULT_TOKEN_SYMBOL) 39 | 40 | contract2 = await initializeContract({ 41 | tokenName: "TOKEN_NAME2", 42 | tokenSymbol: "TEST2", 43 | }) 44 | expect(await contract2.name()).to.be.equal("TOKEN_NAME2") 45 | expect(await contract2.symbol()).to.be.equal("TEST2") 46 | }) 47 | }) 48 | 49 | describe("mintAndTransfer", function () { 50 | it(`can batch mint and transfer and call ownedCredential`, async function () { 51 | await mintAndTransfer({ 52 | contract, 53 | account: owner, 54 | quantity: DEFAULT_MAX_BATCH_SIZE, 55 | }) 56 | 57 | // can call ownedCredential 58 | const credentialId = await ownedCredential({ 59 | contract, 60 | account: owner, 61 | tokenId: 0, 62 | }) 63 | expect(credentialId).to.be.equal(DEFAULT_CREDENTIAL_ID) 64 | }) 65 | it(`can batch mint MAX_BATCH_SIZE(${MAX_BATCH_SIZE}) tokens`, async function () { 66 | const quantity = MAX_BATCH_SIZE 67 | await setMaxBatchSize({ 68 | contract, 69 | account: owner, 70 | newMaxBatchSize: quantity, 71 | }) 72 | await mintAndTransfer({ 73 | contract, 74 | account: owner, 75 | quantity, 76 | }) 77 | }) 78 | it(`Non-owner cannot batch mint`, async function () { 79 | try { 80 | await mintAndTransfer({ 81 | contract, 82 | account: alice, 83 | quantity: DEFAULT_MAX_BATCH_SIZE, 84 | }) 85 | } catch (err) { 86 | assertionError = true 87 | expect(err.reason).to.include(`Ownable: caller is not the owner`) 88 | } finally { 89 | expect(assertionError).to.be.true 90 | } 91 | }) 92 | it(`cannot mint in the case of addresses empty`, async function () { 93 | try { 94 | await mintAndTransfer({ 95 | contract, 96 | account: owner, 97 | quantity: DEFAULT_MAX_BATCH_SIZE, 98 | toAddresses: [], 99 | }) 100 | } catch (err) { 101 | assertionError = true 102 | expect(err.reason).to.include(`The _toAddresses is empty.`) 103 | } finally { 104 | expect(assertionError).to.be.true 105 | } 106 | }) 107 | it(`cannot mint more than maxBatchSize`, async function () { 108 | try { 109 | await mintAndTransfer({ 110 | contract, 111 | account: owner, 112 | quantity: DEFAULT_MAX_BATCH_SIZE + 1, 113 | }) 114 | } catch (err) { 115 | assertionError = true 116 | expect(err.reason).to.include( 117 | `The length of _toAddresses must be less than or equal to _maxBatchSize.` 118 | ) 119 | } finally { 120 | expect(assertionError).to.be.true 121 | } 122 | }) 123 | it(`cannot mint in the case of the different length of toAddresses and imageURIs`, async function () { 124 | try { 125 | await mintAndTransfer({ 126 | contract, 127 | account: owner, 128 | quantity: 1, 129 | toAddresses: dummyAddresses.slice(0, 2), 130 | }) 131 | } catch (err) { 132 | assertionError = true 133 | expect(err.reason).to.include( 134 | `The length of _toAddresses and _imageURIs are NOT same.` 135 | ) 136 | } finally { 137 | expect(assertionError).to.be.true 138 | } 139 | }) 140 | it(`can/cannot mint to same user in diffrent credentials`, async function () { 141 | const toAddresses = dummyAddresses.slice(0, DEFAULT_MAX_BATCH_SIZE) 142 | 143 | // mint of cred1 144 | await mintAndTransfer({ 145 | contract, 146 | account: owner, 147 | quantity: DEFAULT_MAX_BATCH_SIZE, 148 | credentialId: cred1, 149 | toAddresses, 150 | }) 151 | let contractCred1 = await ownedCredential({ 152 | contract, 153 | account: owner, 154 | tokenId: 0, 155 | }) 156 | expect(contractCred1).to.be.equal(cred1) 157 | 158 | // mint of cred2 159 | await mintAndTransfer({ 160 | contract, 161 | account: owner, 162 | quantity: DEFAULT_MAX_BATCH_SIZE, 163 | credentialId: cred2, 164 | toAddresses, 165 | }) 166 | let contractCred2 = await ownedCredential({ 167 | contract, 168 | account: owner, 169 | tokenId: DEFAULT_MAX_BATCH_SIZE, 170 | }) 171 | expect(contractCred2).to.be.equal(cred2) 172 | 173 | // mint to the same address of cred1 174 | // assert that cannot mint to a user who has the same credential already. 175 | try { 176 | await mintAndTransfer({ 177 | contract, 178 | account: owner, 179 | quantity: 1, 180 | credentialId: cred1, 181 | toAddresses: [toAddresses[0]], 182 | }) 183 | } catch (err) { 184 | assertionError = true 185 | expect(err.reason).to.include(`One or more recipient`) 186 | } finally { 187 | expect(assertionError).to.be.true 188 | } 189 | }) 190 | it(`cannot mint to owner address`, async function () { 191 | const toAddresses = [owner.address] 192 | try { 193 | await mintAndTransfer({ 194 | contract, 195 | account: owner, 196 | quantity: 1, 197 | toAddresses, 198 | }) 199 | } catch (err) { 200 | assertionError = true 201 | expect(err.reason).to.include( 202 | `_toAddresses must NOT be included OWNER.` 203 | ) 204 | } finally { 205 | expect(assertionError).to.be.true 206 | } 207 | }) 208 | }) 209 | 210 | describe("getMaxBatchSize and setMaxBatchSize", function () { 211 | it(`can change maxBatchSize`, async function () { 212 | const beforeChange = await getMaxBatchSize({ contract, account: owner }) 213 | expect(beforeChange).to.be.equal(DEFAULT_MAX_BATCH_SIZE) 214 | const newValue = 3 215 | await setMaxBatchSize({ 216 | contract, 217 | account: owner, 218 | newMaxBatchSize: newValue, 219 | }) 220 | const afterChange = await getMaxBatchSize({ contract, account: owner }) 221 | expect(afterChange).to.be.equal(newValue) 222 | }) 223 | }) 224 | 225 | describe("safeTransferFrom and transferFrom", function () { 226 | let steve, john 227 | 228 | beforeEach(async function () { 229 | ;[steve, john] = dummyAddresses 230 | const toAddresses = [alice.address, steve] 231 | 232 | // mint 233 | await mintAndTransfer({ 234 | contract, 235 | account: owner, 236 | quantity: toAddresses.length, 237 | toAddresses: toAddresses, 238 | }) 239 | expect(await contract.ownerOf(0)).to.be.equal(alice.address) 240 | expect(await contract.ownerOf(1)).to.be.equal(steve) 241 | 242 | // Alice delegates any transfer privilege to the owner 243 | await setApprovalForAll({ 244 | contract, 245 | account: alice, 246 | operator: owner.address, 247 | }) 248 | }) 249 | it(`can transfer to new user by safeTransferFrom`, async function () { 250 | // transfer to a new user from alice 251 | await safeTransferFrom({ 252 | contract, 253 | account: owner, 254 | from: alice.address, 255 | to: john, 256 | tokenId: 0, 257 | }) 258 | expect(await contract.ownerOf(0)).to.be.equal(john) 259 | }) 260 | it(`Non-owner cannot transfer by safeTransferFrom`, async function () { 261 | try { 262 | await safeTransferFrom({ 263 | contract, 264 | account: bob, 265 | from: alice.address, 266 | to: john, 267 | tokenId: 0, 268 | }) 269 | } catch (err) { 270 | assertionError = true 271 | expect(err.reason).to.include(`Ownable: caller is not the owner`) 272 | } finally { 273 | expect(assertionError).to.be.true 274 | } 275 | }) 276 | it(`can transfer to new user by transferFrom`, async function () { 277 | // transfer to a new user from alice 278 | await transferFrom({ 279 | contract, 280 | account: owner, 281 | from: alice.address, 282 | to: john, 283 | tokenId: 0, 284 | }) 285 | expect(await contract.ownerOf(0)).to.be.equal(john) 286 | }) 287 | it(`Non-owner cannot transfer by transferFrom`, async function () { 288 | try { 289 | await transferFrom({ 290 | contract, 291 | account: bob, 292 | from: alice.address, 293 | to: john, 294 | tokenId: 0, 295 | }) 296 | } catch (err) { 297 | assertionError = true 298 | expect(err.reason).to.include(`Ownable: caller is not the owner`) 299 | } finally { 300 | expect(assertionError).to.be.true 301 | } 302 | }) 303 | it(`cannot transfer in the case of an invalid token ID`, async function () { 304 | try { 305 | await transferFrom({ 306 | contract, 307 | account: owner, 308 | from: alice.address, 309 | to: john, 310 | tokenId: 1, 311 | }) 312 | } catch (err) { 313 | assertionError = true 314 | expect(err.reason).to.include( 315 | `The from-address is NOT the token ID's owner.` 316 | ) 317 | } finally { 318 | expect(assertionError).to.be.true 319 | } 320 | }) 321 | it(`cannot transfer to a holder who has already the same credential`, async function () { 322 | try { 323 | await transferFrom({ 324 | contract, 325 | account: owner, 326 | from: alice.address, 327 | to: steve, 328 | tokenId: 0, 329 | }) 330 | } catch (err) { 331 | assertionError = true 332 | expect(err.reason).to.include( 333 | `The to-address has the same credential already.` 334 | ) 335 | } finally { 336 | expect(assertionError).to.be.true 337 | } 338 | }) 339 | }) 340 | 341 | describe("updateNFT", function () { 342 | const newImageURI = "https://pitpa.jp/images/0" 343 | const newExternalURI = "https://pitpa.jp/externals/0" 344 | let beforeTokenURI 345 | beforeEach(async function () { 346 | ;[steve] = dummyAddresses 347 | const toAddresses = [steve] 348 | // mint 349 | await mintAndTransfer({ 350 | contract, 351 | account: owner, 352 | quantity: toAddresses.length, 353 | credentialId: cred1, 354 | toAddresses, 355 | }) 356 | expect(await contract.ownerOf(0)).to.be.equal(steve) 357 | beforeTokenURI = await contract.tokenURI(0) 358 | }) 359 | it(`can updaet NFT with NO credential ID change`, async function () { 360 | await updateNFT({ 361 | contract, 362 | account: owner, 363 | tokenId: 0, 364 | credentialId: cred1, 365 | imageURI: newImageURI, 366 | externalURI: newExternalURI, 367 | }) 368 | const credentialId = await ownedCredential({ 369 | contract, 370 | account: owner, 371 | tokenId: 0, 372 | }) 373 | expect(credentialId).to.be.equal(cred1) 374 | expect(await contract.tokenURI(0)).not.to.be.equal(beforeTokenURI) 375 | }) 376 | it(`can updaet NFT with description change`, async function () { 377 | await updateNFT({ 378 | contract, 379 | account: owner, 380 | tokenId: 0, 381 | credentialId: cred1, // NO CHANGE 382 | description: "NEW DESCRIPTION", 383 | imageURI: generateDummyImageUrls(1)[0], // NO CHANGE 384 | externalURI: generateDummyExternalUrls(1)[0], // NO CHANGE 385 | }) 386 | const credentialId = await ownedCredential({ 387 | contract, 388 | account: owner, 389 | tokenId: 0, 390 | }) 391 | expect(credentialId).to.be.equal(cred1) 392 | expect(await contract.tokenURI(0)).not.to.be.equal(beforeTokenURI) 393 | }) 394 | it(`can updaet NFT with a credential ID change`, async function () { 395 | await updateNFT({ 396 | contract, 397 | account: owner, 398 | tokenId: 0, 399 | credentialId: cred2, 400 | imageURI: newImageURI, 401 | externalURI: newExternalURI, 402 | }) 403 | const credentialId = await ownedCredential({ 404 | contract, 405 | account: owner, 406 | tokenId: 0, 407 | }) 408 | expect(credentialId).to.be.equal(cred2) 409 | expect(await contract.tokenURI(0)).not.to.be.equal(beforeTokenURI) 410 | }) 411 | it(`No-owner cannot update NFT`, async function () { 412 | try { 413 | await updateNFT({ 414 | contract, 415 | account: alice, 416 | tokenId: 0, 417 | credentialId: cred1, 418 | imageURI: newImageURI, 419 | externalURI: newExternalURI, 420 | }) 421 | } catch (err) { 422 | assertionError = true 423 | expect(err.reason).to.include(`Ownable: caller is not the owner`) 424 | } finally { 425 | expect(assertionError).to.be.true 426 | } 427 | }) 428 | }) 429 | 430 | describe("transferOwnership", function () { 431 | it(`can transfer`, async function () { 432 | await transferOwnership({ 433 | contract, 434 | account: owner, 435 | newOwner: alice.address, 436 | }) 437 | }) 438 | it(`Non-owner cannot transfer`, async function () { 439 | try { 440 | await transferOwnership({ 441 | contract, 442 | account: alice, 443 | newOwner: bob.address, 444 | }) 445 | } catch (err) { 446 | assertionError = true 447 | expect(err.reason).to.include(`Ownable: caller is not the owner`) 448 | } finally { 449 | expect(assertionError).to.be.true 450 | } 451 | }) 452 | it(`cannot transfer to a holder who has this token`, async function () { 453 | await mintAndTransfer({ 454 | contract, 455 | account: owner, 456 | quantity: 1, 457 | toAddresses: [bob.address], 458 | }) 459 | try { 460 | await transferOwnership({ 461 | contract, 462 | account: owner, 463 | newOwner: bob.address, 464 | }) 465 | } catch (err) { 466 | assertionError = true 467 | expect(err.reason).to.include(`newOwner's balance must be zero.`) 468 | } finally { 469 | expect(assertionError).to.be.true 470 | } 471 | }) 472 | }) 473 | 474 | describe("estimate the total gas in the condition of 400 Tokens", function () { 475 | const targeTokenNum = 400 476 | 477 | function splitAtEqualNum([...array], size = 1) { 478 | return array.reduce( 479 | (pre, _current, index) => 480 | index % size ? pre : [...pre, array.slice(index, index + size)], 481 | [] 482 | ) 483 | } 484 | 485 | async function mintAll(batchSize) { 486 | if (!ENABLE_LARGE_MINTIING_CASES) { 487 | console.log(" => SKIP") 488 | return 489 | } 490 | // change BatchSize 491 | await setMaxBatchSize({ 492 | contract, 493 | account: owner, 494 | newMaxBatchSize: batchSize, 495 | }) 496 | 497 | // split addresses by batchSize and bulk mint sequentially 498 | let totalGasUsed = 0 499 | let targetAddresses = generateWalletAddresses(targeTokenNum) 500 | const batchItems = splitAtEqualNum(targetAddresses, batchSize) 501 | const batchNum = batchItems.length 502 | for (const batchAddresses of batchItems) { 503 | const receipt = await mintAndTransfer({ 504 | contract, 505 | account: owner, 506 | quantity: batchAddresses.length, 507 | toAddresses: batchAddresses, 508 | }) 509 | totalGasUsed += receipt.gasUsed.toNumber() 510 | } 511 | const gasUsedPerBatch = (totalGasUsed / batchNum).toFixed(0) 512 | const gasUsedPerToken = (totalGasUsed / targeTokenNum).toFixed(0) 513 | console.log( 514 | `BatchSize=${batchSize}, BatchNum=${batchNum}, TotalGas=${totalGasUsed}, GasPerBatch=${gasUsedPerBatch}, GasPerToken=${gasUsedPerToken}` 515 | ) 516 | } 517 | 518 | it(`${targeTokenNum} mint cost with batchSize=1`, async function () { 519 | await mintAll(1) 520 | }) 521 | 522 | it(`${targeTokenNum} mint cost with batchSize=5`, async function () { 523 | await mintAll(5) 524 | }) 525 | 526 | it(`${targeTokenNum} mint cost with batchSize=10`, async function () { 527 | await mintAll(10) 528 | }) 529 | 530 | it(`${targeTokenNum} mint cost with batchSize=20`, async function () { 531 | await mintAll(20) 532 | }) 533 | 534 | it(`${targeTokenNum} mint cost with batchSize=MAX_BATCH_SIZE(${MAX_BATCH_SIZE})`, async function () { 535 | await mintAll(MAX_BATCH_SIZE) 536 | }) 537 | }) 538 | }) 539 | 540 | const generateWallet = () => { 541 | const privateKey = `0x${crypto.randomBytes(32).toString("hex")}` 542 | const wallet = new ethers.Wallet(privateKey) 543 | return { privateKey, walletAddress: wallet.address } 544 | } 545 | 546 | const batchGenerateWallet = (num = 5) => { 547 | const wallets = [] 548 | while (num > 0) { 549 | wallets.push(generateWallet()) 550 | num-- 551 | } 552 | return wallets 553 | } 554 | 555 | const generateWalletAddresses = (num = 5) => { 556 | return batchGenerateWallet(num).map((item) => item.walletAddress) 557 | } 558 | 559 | const generateDummyUrls = (num = 5, urlPath = "") => { 560 | const template = "https://example.com" 561 | const items = [] 562 | let index = 0 563 | while (num > 0) { 564 | items.push(`${template}${urlPath}/${index}`) 565 | index++ 566 | num-- 567 | } 568 | return items 569 | } 570 | 571 | const generateDummyImageUrls = (num = 5) => { 572 | return generateDummyUrls(num, "/images") 573 | } 574 | 575 | const generateDummyExternalUrls = (num = 5) => { 576 | return generateDummyUrls(num, "/externals") 577 | } 578 | 579 | const initializeContract = async ({ 580 | contractName = SUBJECT_CONTRACT_NAME, 581 | tokenName = DEFAULT_TOKEN_NAME, 582 | tokenSymbol = DEFAULT_TOKEN_SYMBOL, 583 | maxBatchSize = DEFAULT_MAX_BATCH_SIZE, 584 | }) => { 585 | const nftCredential = await ethers.getContractFactory(contractName) 586 | return await nftCredential.deploy(tokenName, tokenSymbol, maxBatchSize) 587 | } 588 | 589 | const mintAndTransfer = async ({ 590 | contract, 591 | account, 592 | quantity, 593 | credentialId = DEFAULT_CREDENTIAL_ID, 594 | description = DEFAULT_TOKEN_DESCRIPTION, 595 | toAddresses = null, 596 | }) => { 597 | const mintTx = await contract 598 | .connect(account) 599 | .mintAndTransfer( 600 | credentialId, 601 | description, 602 | toAddresses || generateWalletAddresses(quantity), 603 | generateDummyImageUrls(quantity), 604 | generateDummyExternalUrls(quantity) 605 | ) 606 | return await mintTx.wait() 607 | } 608 | 609 | const getMaxBatchSize = async ({ contract, account }) => { 610 | return await contract.connect(account).getMaxBatchSize() 611 | } 612 | 613 | const setMaxBatchSize = async ({ contract, account, newMaxBatchSize }) => { 614 | const tx = await contract.connect(account).setMaxBatchSize(newMaxBatchSize) 615 | return await tx.wait() 616 | } 617 | 618 | const ownedCredential = async ({ contract, account, tokenId }) => { 619 | return await contract.connect(account).ownedCredential(tokenId) 620 | } 621 | 622 | const safeTransferFrom = async ({ contract, account, from, to, tokenId }) => { 623 | const tx = await contract 624 | .connect(account) 625 | ["safeTransferFrom(address,address,uint256)"](from, to, tokenId) 626 | return await tx.wait() 627 | } 628 | 629 | const transferFrom = async ({ contract, account, from, to, tokenId }) => { 630 | const tx = await contract.connect(account).transferFrom(from, to, tokenId) 631 | return await tx.wait() 632 | } 633 | 634 | const setApprovalForAll = async ({ 635 | contract, 636 | account, 637 | operator, 638 | approved = true, 639 | }) => { 640 | const tx = await contract 641 | .connect(account) 642 | .setApprovalForAll(operator, approved) 643 | return await tx.wait() 644 | } 645 | 646 | const updateNFT = async ({ 647 | contract, 648 | account, 649 | tokenId, 650 | credentialId, 651 | description = DEFAULT_TOKEN_DESCRIPTION, 652 | imageURI, 653 | externalURI, 654 | }) => { 655 | const tx = await contract 656 | .connect(account) 657 | .updateNFT(tokenId, credentialId, description, imageURI, externalURI) 658 | return await tx.wait() 659 | } 660 | 661 | const transferOwnership = async ({ contract, account, newOwner }) => { 662 | const tx = await contract.connect(account).transferOwnership(newOwner) 663 | return await tx.wait() 664 | } 665 | -------------------------------------------------------------------------------- /templates/vc-test.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 102 | 103 | 104 | 105 | 106 | 107 | 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 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | Sample Online Academy 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | CERTIFICATE OF COMPLETION 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | This certificate is presented to 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | {{HOLDER_NAME}} 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | for completing {{CREDENTIAL_NAME}} 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | Given this Octorber of 2022. 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | _______________ 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | Example Name 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | Processor 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | --------------------------------------------------------------------------------