├── 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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------