├── .gitignore ├── env.example ├── start-local-environment.sh ├── test └── sample-test.js ├── hardhat.config.js ├── config └── default.js ├── package.json ├── contracts └── Minty.sol ├── src ├── deploy.js ├── index.js └── minty.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | minty-deployment.json 4 | config/default.env 5 | .env 6 | 7 | #Hardhat files 8 | cache 9 | artifacts 10 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Please make sure to remove the 'example' of this file extention. File name should be '.env' 2 | 3 | # Required ✋ 4 | ALCHEMY_KEY = "" 5 | ACCOUNT_PRIVATE_KEY = "" 6 | 7 | # Optional 🤘 8 | # Input your NFT Storage Key 9 | PINNING_SERVICE_NAME = "https://nft.storage/api" 10 | PINNING_SERVICE_ENDPOINT = "nft.storage" 11 | PINNING_SERVICE_KEY = "" 12 | 13 | # Optional 🤘 14 | # Add your etherscan if you want to verify your contract 15 | ETHERSCAN_API_KEY = "" 16 | -------------------------------------------------------------------------------- /start-local-environment.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | echo "Compiling smart contract" 4 | npx hardhat compile 5 | if [ $? -ne 0 ]; then 6 | echo "compilation error" 7 | exit 1 8 | fi 9 | 10 | # if there's no local ipfs repo, initialize one 11 | if [ ! -d "$HOME/.ipfs" ]; then 12 | npx ipfs init 13 | fi 14 | 15 | echo "Running IPFS and development blockchain" 16 | run_eth_cmd="npx hardhat node" 17 | run_ipfs_cmd="npx ipfs daemon" 18 | 19 | npx concurrently -n eth,ipfs -c yellow,blue "$run_eth_cmd" "$run_ipfs_cmd" 20 | -------------------------------------------------------------------------------- /test/sample-test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | 3 | describe("Greeter", function() { 4 | it("Should return the new greeting once it's changed", async function() { 5 | const Greeter = await ethers.getContractFactory("Greeter"); 6 | const greeter = await Greeter.deploy("Hello, world!"); 7 | 8 | await greeter.deployed(); 9 | expect(await greeter.greet()).to.equal("Hello, world!"); 10 | 11 | await greeter.setGreeting("Hola, mundo!"); 12 | expect(await greeter.greet()).to.equal("Hola, mundo!"); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /hardhat.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type import('hardhat/config').HardhatUserConfig 3 | */ 4 | 5 | require("@nomiclabs/hardhat-waffle"); 6 | require("@nomiclabs/hardhat-ethers"); 7 | require("@nomiclabs/hardhat-etherscan"); 8 | require("dotenv").config(); 9 | const { ALCHEMY_KEY, ACCOUNT_PRIVATE_KEY, ETHERSCAN_API_KEY } = process.env; 10 | 11 | module.exports = { 12 | solidity: "0.8.1", 13 | defaultNetwork: "rinkeby", 14 | networks: { 15 | hardhat: {}, 16 | rinkeby: { 17 | url: `https://eth-rinkeby.alchemyapi.io/v2/${ALCHEMY_KEY}`, 18 | accounts: [`0x${ACCOUNT_PRIVATE_KEY}`], 19 | }, 20 | mumbai: { 21 | url: `https://polygon-mumbai.g.alchemy.com/v2/${ALCHEMY_KEY}`, 22 | accounts: [`0x${ACCOUNT_PRIVATE_KEY}`], 23 | }, 24 | ethereum: { 25 | chainId: 1, 26 | url: `https://eth-mainnet.alchemyapi.io/v2/${ALCHEMY_KEY}`, 27 | accounts: [`0x${ACCOUNT_PRIVATE_KEY}`], 28 | }, 29 | polygon: { 30 | chainId: 137, 31 | url: `https://polygon-mainnet.g.alchemy.com/v2/${ALCHEMY_KEY}`, 32 | accounts: [`0x${ACCOUNT_PRIVATE_KEY}`], 33 | }, 34 | }, 35 | etherscan: { 36 | apiKey: ETHERSCAN_API_KEY, 37 | }, 38 | }; -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const { PINNING_SERVICE_KEY, PINNING_SERVICE_NAME, PINNING_SERVICE_ENDPOINT } = 3 | process.env; 4 | const config = { 5 | // The pinningService config tells minty what remote pinning service to use for pinning the IPFS data for a token. 6 | // The values are read in from environment variables, to discourage checking credentials into source control. 7 | // You can make things easy by creating a .env file with your environment variable definitions. See the example files 8 | // pinata.env.example and nft.storage.env.example in this directory for templates you can use to get up and running. 9 | pinningService: { 10 | name: "{PINNING_SERVICE_NAME}", 11 | endpoint: "{PINNING_SERVICE_ENDPOINT}", 12 | key: "{PINNING_SERVICE_KEY}", 13 | }, 14 | 15 | // When the Minty smart contract is deployed, the contract address and other details will be written to this file. 16 | // Commands that interact with the smart contract (minting, etc), will load the file to connect to the deployed contract. 17 | deploymentConfigFile: "minty-deployment.json", 18 | 19 | // If you're running IPFS on a non-default port, update this URL. If you're using the IPFS defaults, you should be all set. 20 | ipfsApiUrl: "http://localhost:5001", 21 | 22 | // If you're running the local IPFS gateway on a non-default port, or if you want to use a public gatway when displaying IPFS gateway urls, edit this. 23 | ipfsGatewayUrl: "http://localhost:8080/ipfs", 24 | }; 25 | 26 | module.exports = config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minty", 3 | "version": "1.0.0", 4 | "description": "Minty is an example of how to _mint_ non-fungible tokens (NFTs) while storing the associated data on IPFS. You can also use Minty to pin your data on an IPFS pinning service such as [nft.storage](https://nft.storage) and [Pinata](https://pinata.cloud).", 5 | "main": "src/minty.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "bin": { 10 | "minty": "src/index.js" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@nomiclabs/hardhat-ethers": "^2.0.1", 17 | "@nomiclabs/hardhat-etherscan": "^3.0.0", 18 | "@nomiclabs/hardhat-waffle": "^2.0.1", 19 | "chai": "^4.3.3", 20 | "ethereum-waffle": "^3.3.0", 21 | "ethers": "^5.0.31", 22 | "hardhat": "^2.1.1" 23 | }, 24 | "dependencies": { 25 | "@openzeppelin/contracts": "^4.4.0", 26 | "chalk": "^4.1.0", 27 | "cids": "^1.1.6", 28 | "commander": "^7.1.0", 29 | "concurrently": "^6.0.0", 30 | "dotenv": "^10.0.0", 31 | "getconfig": "^4.5.0", 32 | "go-ipfs": "^0.12.0", 33 | "inquirer": "^8.0.0", 34 | "ipfs-http-client": "^49.0.4", 35 | "it-all": "^1.0.5", 36 | "json-colorizer": "^2.2.2", 37 | "multiaddr": "^8.1.2", 38 | "polygonscan-api": "^1.0.4" 39 | }, 40 | "directories": { 41 | "test": "test" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/yusefnapora/minty.git" 46 | }, 47 | "bugs": { 48 | "url": "https://github.com/yusefnapora/minty/issues" 49 | }, 50 | "homepage": "https://github.com/yusefnapora/minty#readme" 51 | } 52 | -------------------------------------------------------------------------------- /contracts/Minty.sol: -------------------------------------------------------------------------------- 1 | //SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^ 0.8.1; 4 | 5 | import "hardhat/console.sol"; 6 | import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; 7 | import "@openzeppelin/contracts/utils/Counters.sol"; 8 | import "@openzeppelin/contracts/security/PullPayment.sol"; 9 | import "@openzeppelin/contracts/access/Ownable.sol"; 10 | 11 | contract Minty is ERC721, PullPayment, Ownable { 12 | using Counters for Counters.Counter; 13 | 14 | uint256 public constant MINT_PRICE = 0.08 ether; 15 | 16 | Counters.Counter private currentTokenId; 17 | 18 | /// @dev Base token URI used as a prefix by tokenURI(). 19 | string public baseTokenURI; 20 | 21 | mapping(uint256 => string) private _tokenURIs; 22 | constructor(string memory tokenName, string memory symbol) ERC721(tokenName, symbol) { 23 | baseTokenURI = "ipfs://"; 24 | } 25 | 26 | function mintToken(address owner, string memory metadataURI) 27 | public payable returns(uint256) { 28 | require(msg.value == MINT_PRICE, "Transaction value did not equal the mint price"); 29 | currentTokenId.increment(); 30 | uint256 id = currentTokenId.current(); 31 | _safeMint(owner, id); 32 | _setTokenURI(id, metadataURI); 33 | return id; 34 | } 35 | 36 | function _setTokenURI(uint256 tokenId, string memory _tokenURI) internal virtual { 37 | require(_exists(tokenId), "ERC721Metadata: URI set of nonexistent token"); 38 | _tokenURIs[tokenId] = _tokenURI; 39 | } 40 | 41 | function tokenURI(uint256 tokenId) public view virtual override returns(string memory) { 42 | require(_exists(tokenId), "ERC721Metadata: URI query for nonexistent token"); 43 | string memory _tokenURI = _tokenURIs[tokenId]; 44 | string memory base = _baseURI(); 45 | // If there is no base URI, return the token URI. 46 | if (bytes(base).length == 0) { 47 | return _tokenURI; 48 | } 49 | // If both are set, concatenate the baseURI and tokenURI (via abi.encodePacked). 50 | if (bytes(_tokenURI).length > 0) { 51 | return string(abi.encodePacked(base, _tokenURI)); 52 | } 53 | // If there is a baseURI but no tokenURI, concatenate the tokenID to the baseURI. 54 | return string(abi.encodePacked(base, tokenId)); 55 | } 56 | 57 | /// @dev Returns an URI for a given token ID 58 | function _baseURI() internal view virtual override returns(string memory) { 59 | return baseTokenURI; 60 | } 61 | 62 | /// @dev Sets the base token URI prefix. 63 | function setBaseTokenURI(string memory _baseTokenURI) public onlyOwner { 64 | baseTokenURI = _baseTokenURI; 65 | } 66 | 67 | /// @dev Overridden in order to make it an onlyOwner function 68 | function withdrawPayments(address payable payee) public override onlyOwner virtual { 69 | (bool os, ) = payable(owner()).call{value: address(this).balance}(""); 70 | require(os); 71 | super.withdrawPayments(payee); 72 | } 73 | } -------------------------------------------------------------------------------- /src/deploy.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs/promises"); 2 | const { F_OK } = require("fs"); 3 | 4 | const inquirer = require("inquirer"); 5 | const { BigNumber } = require("ethers"); 6 | const config = require("getconfig"); 7 | 8 | const CONTRACT_NAME = "Minty"; 9 | 10 | async function deployContract(name, symbol) { 11 | const hardhat = require("hardhat"); 12 | const network = hardhat.network.name; 13 | 14 | console.log( 15 | `deploying contract for token ${name} (${symbol}) to network "${network}"...` 16 | ); 17 | const Minty = await hardhat.ethers.getContractFactory(CONTRACT_NAME); 18 | const minty = await Minty.deploy(name, symbol); 19 | 20 | await minty.deployed(); 21 | console.log( 22 | `deployed contract for token ${name} (${symbol}) to ${minty.address} (network: ${network})` 23 | ); 24 | 25 | return deploymentInfo(hardhat, minty); 26 | } 27 | 28 | function deploymentInfo(hardhat, minty) { 29 | return { 30 | network: hardhat.network.name, 31 | contract: { 32 | name: CONTRACT_NAME, 33 | address: minty.address, 34 | signerAddress: minty.signer.address, 35 | abi: minty.interface.format(), 36 | }, 37 | }; 38 | } 39 | 40 | async function saveDeploymentInfo(info, filename = undefined) { 41 | if (!filename) { 42 | filename = config.deploymentConfigFile || "minty-deployment.json"; 43 | } 44 | const exists = await fileExists(filename); 45 | if (exists) { 46 | const overwrite = await confirmOverwrite(filename); 47 | if (!overwrite) { 48 | return false; 49 | } 50 | } 51 | 52 | console.log(`Writing deployment info to ${filename}`); 53 | const content = JSON.stringify(info, null, 2); 54 | await fs.writeFile(filename, content, { encoding: "utf-8" }); 55 | return true; 56 | } 57 | 58 | async function loadDeploymentInfo() { 59 | let { deploymentConfigFile } = config; 60 | if (!deploymentConfigFile) { 61 | console.log( 62 | 'no deploymentConfigFile field found in minty config. attempting to read from default path "./minty-deployment.json"' 63 | ); 64 | deploymentConfigFile = "minty-deployment.json"; 65 | } 66 | const content = await fs.readFile(deploymentConfigFile, { encoding: "utf8" }); 67 | deployInfo = JSON.parse(content); 68 | try { 69 | validateDeploymentInfo(deployInfo); 70 | } catch (e) { 71 | throw new Error( 72 | `error reading deploy info from ${deploymentConfigFile}: ${e.message}` 73 | ); 74 | } 75 | return deployInfo; 76 | } 77 | 78 | function validateDeploymentInfo(deployInfo) { 79 | const { contract } = deployInfo; 80 | if (!contract) { 81 | throw new Error('required field "contract" not found'); 82 | } 83 | const required = (arg) => { 84 | if (!deployInfo.contract.hasOwnProperty(arg)) { 85 | throw new Error(`required field "contract.${arg}" not found`); 86 | } 87 | }; 88 | 89 | required("name"); 90 | required("address"); 91 | required("abi"); 92 | } 93 | 94 | async function fileExists(path) { 95 | try { 96 | await fs.access(path, F_OK); 97 | return true; 98 | } catch (e) { 99 | return false; 100 | } 101 | } 102 | 103 | async function confirmOverwrite(filename) { 104 | const answers = await inquirer.prompt([ 105 | { 106 | type: "confirm", 107 | name: "overwrite", 108 | message: `File ${filename} exists. Overwrite it?`, 109 | default: false, 110 | }, 111 | ]); 112 | return answers.overwrite; 113 | } 114 | 115 | module.exports = { 116 | deployContract, 117 | loadDeploymentInfo, 118 | saveDeploymentInfo, 119 | }; 120 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // This file contains the main entry point for the command line `minty` app, and the command line option parsing code. 4 | // See minty.js for the core functionality. 5 | 6 | const fs = require("fs/promises"); 7 | const path = require("path"); 8 | const { Command } = require("commander"); 9 | const inquirer = require("inquirer"); 10 | const chalk = require("chalk"); 11 | const colorize = require("json-colorizer"); 12 | const config = require("getconfig"); 13 | const { MakeMinty } = require("./minty"); 14 | const { deployContract, saveDeploymentInfo } = require("./deploy"); 15 | 16 | const colorizeOptions = { 17 | pretty: true, 18 | colors: { 19 | STRING_KEY: "blue.bold", 20 | STRING_LITERAL: "green", 21 | }, 22 | }; 23 | 24 | async function main() { 25 | const program = new Command(); 26 | 27 | // commands 28 | program 29 | .command("mint ") 30 | .description("create a new NFT from an image file") 31 | .option("-n, --name ", "The name of the NFT") 32 | .option("-d, --description ", "A description of the NFT") 33 | .option( 34 | "-o, --owner
", 35 | "The ethereum address that should own the NFT." + 36 | "If not provided, defaults to the first signing address." 37 | ) 38 | .action(createNFT); 39 | 40 | program 41 | .command("show ") 42 | .description("get info about an NFT using its token ID") 43 | .option( 44 | "-c, --creation-info", 45 | "include the creator address and block number the NFT was minted" 46 | ) 47 | .action(getNFT); 48 | 49 | program 50 | .command("transfer ") 51 | .description("transfer an NFT to a new owner") 52 | .action(transferNFT); 53 | 54 | program 55 | .command("pin ") 56 | .description('"pin" the data for an NFT to a remote IPFS Pinning Service') 57 | .action(pinNFTData); 58 | 59 | program 60 | .command("deploy") 61 | .description("deploy an instance of the Minty NFT contract") 62 | .option( 63 | "-o, --output ", 64 | "Path to write deployment info to", 65 | config.deploymentConfigFile || "minty-deployment.json" 66 | ) 67 | .option("-n, --name ", "The name of the token contract", "Julep") 68 | .option( 69 | "-s, --symbol ", 70 | "A short symbol for the tokens in this contract", 71 | "JLP" 72 | ) 73 | .action(deploy); 74 | 75 | // The hardhat and getconfig modules both expect to be running from the root directory of the project, 76 | // so we change the current directory to the parent dir of this script file to make things work 77 | // even if you call minty from elsewhere 78 | const rootDir = path.join(__dirname, ".."); 79 | process.chdir(rootDir); 80 | 81 | await program.parseAsync(process.argv); 82 | } 83 | 84 | // ---- command action functions 85 | 86 | async function createNFT(imagePath, options) { 87 | const minty = await MakeMinty(); 88 | 89 | // prompt for missing details if not provided as cli args 90 | const answers = await promptForMissing(options, { 91 | name: { 92 | message: "Enter a name for your new NFT: ", 93 | }, 94 | 95 | description: { 96 | message: "Enter a description for your new NFT: ", 97 | }, 98 | }); 99 | 100 | const nft = await minty.createNFTFromAssetFile(imagePath, answers); 101 | console.log("🌿 Minted a new NFT: "); 102 | 103 | alignOutput([ 104 | ["Token ID:", chalk.green(nft.tokenId)], 105 | ["Metadata Address:", chalk.blue(nft.metadataURI)], 106 | ["Metadata Gateway URL:", chalk.blue(nft.metadataGatewayURL)], 107 | ["Asset Address:", chalk.blue(nft.assetURI)], 108 | ["Asset Gateway URL:", chalk.blue(nft.assetGatewayURL)], 109 | ]); 110 | console.log("NFT Metadata:"); 111 | console.log(colorize(JSON.stringify(nft.metadata), colorizeOptions)); 112 | } 113 | 114 | async function getNFT(tokenId, options) { 115 | const { creationInfo: fetchCreationInfo } = options; 116 | const minty = await MakeMinty(); 117 | const nft = await minty.getNFT(tokenId, { fetchCreationInfo }); 118 | 119 | const output = [ 120 | ["Token ID:", chalk.green(nft.tokenId)], 121 | ["Owner Address:", chalk.yellow(nft.ownerAddress)], 122 | ]; 123 | if (nft.creationInfo) { 124 | output.push([ 125 | "Creator Address:", 126 | chalk.yellow(nft.creationInfo.creatorAddress), 127 | ]); 128 | output.push(["Block Number:", nft.creationInfo.blockNumber]); 129 | } 130 | output.push(["Metadata Address:", chalk.blue(nft.metadataURI)]); 131 | output.push(["Metadata Gateway URL:", chalk.blue(nft.metadataGatewayURL)]); 132 | output.push(["Asset Address:", chalk.blue(nft.assetURI)]); 133 | output.push(["Asset Gateway URL:", chalk.blue(nft.assetGatewayURL)]); 134 | alignOutput(output); 135 | 136 | console.log("NFT Metadata:"); 137 | console.log(colorize(JSON.stringify(nft.metadata), colorizeOptions)); 138 | } 139 | 140 | async function transferNFT(tokenId, toAddress) { 141 | const minty = await MakeMinty(); 142 | 143 | await minty.transferToken(tokenId, toAddress); 144 | console.log( 145 | `🌿 Transferred token ${chalk.green(tokenId)} to ${chalk.yellow(toAddress)}` 146 | ); 147 | } 148 | 149 | async function pinNFTData(tokenId) { 150 | const minty = await MakeMinty(); 151 | const { assetURI, metadataURI } = await minty.pinTokenData(tokenId); 152 | console.log(`🌿 Pinned all data for token id ${chalk.green(tokenId)}`); 153 | } 154 | 155 | async function deploy(options) { 156 | const filename = options.output; 157 | const info = await deployContract(options.name, options.symbol); 158 | await saveDeploymentInfo(info, filename); 159 | } 160 | 161 | // ---- helpers 162 | 163 | async function promptForMissing(cliOptions, prompts) { 164 | const questions = []; 165 | for (const [name, prompt] of Object.entries(prompts)) { 166 | prompt.name = name; 167 | prompt.when = (answers) => { 168 | if (cliOptions[name]) { 169 | answers[name] = cliOptions[name]; 170 | return false; 171 | } 172 | return true; 173 | }; 174 | questions.push(prompt); 175 | } 176 | return inquirer.prompt(questions); 177 | } 178 | 179 | function alignOutput(labelValuePairs) { 180 | const maxLabelLength = labelValuePairs 181 | .map(([l, _]) => l.length) 182 | .reduce((len, max) => (len > max ? len : max)); 183 | for (const [label, value] of labelValuePairs) { 184 | console.log(label.padEnd(maxLabelLength + 1), value); 185 | } 186 | } 187 | 188 | // ---- main entry point when running as a script 189 | 190 | // make sure we catch all errors 191 | main() 192 | .then(() => { 193 | process.exit(0); 194 | }) 195 | .catch((err) => { 196 | console.error(err); 197 | process.exit(1); 198 | }); 199 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mint NFTs 2 | 3 | Minty is an example of how to _mint_ non-fungible tokens (NFTs) while storing the 4 | associated data on IPFS. You can also use Minty to pin your data on an IPFS pinning service such as [nft.storage](https://nft.storage) and [Pinata](https://pinata.cloud). 5 | 6 | ## Setup 7 | 8 | To install and run Minty, you must have NPM installed. Windows is not currently supported. 9 | 10 | 1. Clone this repository and move into the `minty` directory: 11 | 12 | ```shell 13 | git clone https://github.com/oneandzeros-co/MintNFTs 14 | cd MintNFTs 15 | ``` 16 | 17 | 1. Install the NPM dependencies: 18 | 19 | ```shell 20 | npm install 21 | ``` 22 | 23 | 1. Add the `minty` command to your `$PATH`. This makes it easier to run Minty from anywhere on your computer: 24 | 25 | ``` 26 | npm link 27 | ``` 28 | 1. Create a `.env` file and input your alchemy and wallet private key: 29 | 30 | ```shell 31 | # Required ✋ 32 | ALCHEMY_KEY = "" 33 | ACCOUNT_PRIVATE_KEY = "" 34 | ``` 35 | 36 | Please check the env.example file provided for reference. 37 | 38 | 1. Run the `start-local-environment.sh` script to start the local Ethereum testnet and IPFS daemon: 39 | 40 | ```shell 41 | ./start-local-environment.sh 42 | 43 | > Compiling smart contract 44 | > Compiling 16 files with 0.7.3 45 | > ... 46 | ``` 47 | 48 | This command continues to run. All further commands must be entered in another terminal window. 49 | 50 | ## Deploy the contract 51 | 52 | Before running any of the other `minty` commands, you'll need to deploy an instance of the 53 | smart contract: 54 | 55 | ```shell 56 | minty deploy 57 | 58 | > deploying contract for token Julep (JLP) to network "localhost"... 59 | > deployed contract for token Julep (JLP) to 0x5FbDB2315678afecb367f032d93F642f64180aa3 (network: localhost) 60 | > Writing deployment info to minty-deployment.json 61 | ``` 62 | 63 | The terminal window running the `./start-local-environment.sh` will output something like: 64 | 65 | ```shell 66 | > [eth] eth_chainId 67 | > [eth] eth_getTransactionByHash 68 | > [eth] eth_blockNumber 69 | > eth_chainId (2)Id 70 | > eth_getTransactionReceipt 71 | ``` 72 | 73 | This deploys to the network configured in [`hardhat.config.js`](./hardhat.config.js), which is set to the `localhost` network by default. If you get an error about not being able to reach the network, make sure to run the local development network with `./start-local-environment.sh`. 74 | 75 | When the contract is deployed, the address and other information about the deployment is written to `minty-deployment.json`. This file must be present for subsequent commands to work. 76 | 77 | To deploy to an ethereum testnet, see the [Hardhat configuration docs](https://hardhat.org/config/) to learn how to configure a JSON-RPC node. Once you've added a new network to the Hardhat config, you can use it by setting the `HARDHAT_NETWORK` environment variable to the name of the new network when you run `minty` commands. Alternatively, you can change the `defaultNetwork` in `hardhat.config.js` to always prefer the new network. 78 | 79 | Deploying this contract to the Ethereum mainnet is a bad idea since the contract itself lacks any access control. See the [Open Zeppelin article](https://docs.openzeppelin.com/contracts/3.x/access-control) about what access control is, and why it's important to have. 80 | 81 | ## Configuration 82 | 83 | Configuration are stored in [`./config/default.js`](./config/default.js). 84 | 85 | The `./start-local-environment.sh` script will try to run a local IPFS daemon, which Minty will connect to on its default port. If you've already installed IPFS and configured it to use a non-standard API port, you may need to change the `ipfsApiUrl` field to set the correct API address. 86 | 87 | The `pinningService` configuration option is used by the `minty pin` command to persist IPFS data to a remote pinning service. 88 | 89 | The default `pinningService` configuration reads in the name, API endpoint and API key from environment variables, to make it a little harder to accidentally check an access token into version control. 90 | 91 | You can define these values in a [dotenv file](https://www.npmjs.com/package/dotenv) so you don't need to set them in each shell session. Just create a file called `.env` inside the `config` directory or in the root directory of the repository, and make it look similar to this: 92 | 93 | ```shell 94 | PINNING_SERVICE_KEY="Paste your nft.storage JWT token inside the quotes!" 95 | PINNING_SERVICE_NAME="nft.storage" 96 | PINNING_SERVICE_ENDPOINT="https://nft.storage/api" 97 | ``` 98 | 99 | The `.env` file will be ignored by git, so you don't need to worry about checking it in by accident. 100 | 101 | The snippet above will configure minty to use [nft.storage](https://nft.storage), a free service offered by Protocol Labs for storing public NFT data. You can find an example `.env` file for **nft.storage** at [`config/nft.storage.env.example`](./config/nft.storage.env.example). 102 | 103 | Any service that implements the [IPFS Remote Pinning API](https://ipfs.github.io/pinning-services-api-spec) can be used with Minty. To use [Pinata](https://pinata.cloud), check out the example at [`config/pinata.env.example`](./config/pinata.env.example). 104 | 105 | With no pinning service configured, everything apart from the `minty pin` command should still work. 106 | 107 | ### Mint a new NFT 108 | 109 | Once you have the local Ethereum network and IPFS daemon running, minting an NFT is incredibly simple. Just specify what you want to _tokenize_, the name of the NFT, and a description to tell users what the NFT is for: 110 | 111 | ```shell 112 | minty mint ~/ticket.txt --name "Moon Flight #1" --description "This ticket serves as proof-of-ownership of a first-class seat on a flight to the moon." 113 | 114 | > 🌿 Minted a new NFT: 115 | > Token ID: 1 116 | > Metadata URI: ipfs://bafybeic3ui4dj5dzsvqeiqbxjgg3fjmfmiinb3iyd2trixj2voe4jtefgq/metadata.json 117 | > Metadata Gateway URL: http://localhost:8080/ipfs/bafybeic3ui4dj5dzsvqeiqbxjgg3fjmfmiinb3iyd2trixj2voe4jtefgq/metadata.json 118 | > Asset URI: ipfs://bafybeihhii26gwp4w7b7w7d57nuuqeexau4pnnhrmckikaukjuei2dl3fq/ticket.txt 119 | > Asset Gateway URL: http://localhost:8080/ipfs/bafybeihhii26gwp4w7b7w7d57nuuqeexau4pnnhrmckikaukjuei2dl3fq/ticket.txt 120 | > NFT Metadata: 121 | > { 122 | > "name": "Moon Flight #1", 123 | > "description": "This ticket serves as proof-of-ownership of a first-class seat on a flight to the moon.", 124 | > "image": "ipfs://bafybeihhii26gwp4w7b7w7d57nuuqeexau4pnnhrmckikaukjuei2dl3fq/ticket.txt" 125 | > } 126 | ``` 127 | 128 | ### Show details of an existing NFT 129 | 130 | You can view the details of each individual NFT by calling `show` along with the ID of the NFT: 131 | 132 | ```shell 133 | minty show 1 134 | 135 | > Token ID: 1 136 | > Owner Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 137 | > Metadata URI: ipfs://bafybeic3ui4dj5dzsvqeiqbxjgg3fjmfmiinb3iyd2trixj2voe4jtefgq/metadata.json 138 | > ... 139 | ``` 140 | 141 | ### Pin IPFS assets for an NFT 142 | 143 | The assets for new tokens are stored in a local IPFS repository which is only _online_ while a local IPFS daemon is running. The `start-local-environment.sh` script starts a local daemon for you if you aren't already running and IPFS daemon. If you are, then the script just uses the daemon you already have. 144 | 145 | To make the data highly available without needing to run a local IPFS daemon 24/7, you can request that a [Remote Pinning Service](https://ipfs.github.io/pinning-services-api-spec) like [Pinata](https://pinata.cloud/) or [nft.storage](https://nft.storage) store a copy of your IPFS data on their IPFS nodes. 146 | 147 | To pin the data for token, use the `minty pin` command: 148 | 149 | ```shell 150 | minty pin 1 151 | 152 | > Pinning asset data (ipfs://bafybeihhii26gwp4w7b7w7d57nuuqeexau4pnnhrmckikaukjuei2dl3fq/ticket.txt) for token id 1.... 153 | > Pinning metadata (ipfs://bafybeic3ui4dj5dzsvqeiqbxjgg3fjmfmiinb3iyd2trixj2voe4jtefgq/metadata.json) for token id 1... 154 | > 🌿 Pinned all data for token id 1 155 | ``` 156 | 157 | The `pin` command looks for some configuration info to connect to the remote pinning service. See the [Configuration section](#configuration) above for details. 158 | 159 | ### Verify Contract for EtherScan 160 | 161 | You can now verify your NFT contract on etherscan. Run this command line, but make sure to replace the {CONTRACT_ADDRESS} with your deployed contract address. Also, please replcae the "CONTRACT_NAME" and "CONTRACT_TICKER" with your contract name and ticker symbol. 162 | 163 | ```shell 164 | npx hardhat verify --network rinkeby {CONTRACT_ADDRESS} "CONTRACT_NAME" "CONTRACT_TICKER" 165 | 166 | Successfully submitted source code for contract 167 | contracts/Minty.sol:Minty at 0xcC17B83373fDb75C5e1F6F074437249C53A026F5 168 | for verification on the block explorer. Waiting for verification result... 169 | 170 | Successfully verified contract Minty on Etherscan. 171 | https://rinkeby.etherscan.io/address/0xcC17B83373fDb75C5e1F6F074437249C53A026F5#code 172 | 173 | ``` 174 | 175 | ### Credits ✍️✍️✍️ 176 | 177 | We would like to give credit to the orginal creator of this repository we forked. 178 | https://github.com/yusefnapora/minty 179 | -------------------------------------------------------------------------------- /src/minty.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs/promises"); 2 | const path = require("path"); 3 | 4 | const CID = require("cids"); 5 | const ipfsClient = require("ipfs-http-client"); 6 | const all = require("it-all"); 7 | const uint8ArrayConcat = require("uint8arrays/concat"); 8 | const uint8ArrayToString = require("uint8arrays/to-string"); 9 | const { BigNumber } = require("ethers"); 10 | const { ethers } = require("ethers"); 11 | 12 | const { loadDeploymentInfo } = require("./deploy"); 13 | 14 | // The getconfig package loads configuration from files located in the the `config` directory. 15 | // See https://www.npmjs.com/package/getconfig for info on how to override the default config for 16 | // different environments (e.g. testnet, mainnet, staging, production, etc). 17 | const config = require("getconfig"); 18 | 19 | // ipfs.add parameters for more deterministic CIDs 20 | const ipfsAddOptions = { 21 | cidVersion: 1, 22 | hashAlg: "sha2-256", 23 | }; 24 | 25 | /** 26 | * Construct and asynchronously initialize a new Minty instance. 27 | * @returns {Promise} a new instance of Minty, ready to mint NFTs. 28 | */ 29 | async function MakeMinty() { 30 | const m = new Minty(); 31 | await m.init(); 32 | return m; 33 | } 34 | 35 | /** 36 | * Minty is the main object responsible for storing NFT data and interacting with the smart contract. 37 | * Before constructing, make sure that the contract has been deployed and a deployment 38 | * info file exists (the default location is `minty-deployment.json`) 39 | * 40 | * Minty requires async initialization, so the Minty class (and its constructor) are not exported. 41 | * To make one, use the async {@link MakeMinty} function. 42 | */ 43 | class Minty { 44 | constructor() { 45 | this.ipfs = null; 46 | this.contract = null; 47 | this.deployInfo = null; 48 | this._initialized = false; 49 | } 50 | 51 | async init() { 52 | if (this._initialized) { 53 | return; 54 | } 55 | this.hardhat = require("hardhat"); 56 | 57 | // The Minty object expects that the contract has already been deployed, with 58 | // details written to a deployment info file. The default location is `./minty-deployment.json`, 59 | // in the config. 60 | this.deployInfo = await loadDeploymentInfo(); 61 | 62 | // connect to the smart contract using the address and ABI from the deploy info 63 | const { abi, address } = this.deployInfo.contract; 64 | this.contract = await this.hardhat.ethers.getContractAt(abi, address); 65 | 66 | // create a local IPFS node 67 | this.ipfs = ipfsClient(config.ipfsApiUrl); 68 | 69 | this._initialized = true; 70 | } 71 | 72 | ////////////////////////////////////////////// 73 | // ------ NFT Creation 74 | ////////////////////////////////////////////// 75 | 76 | /** 77 | * Create a new NFT from the given asset data. 78 | * 79 | * @param {Buffer|Uint8Array} content - a Buffer or UInt8Array of data (e.g. for an image) 80 | * @param {object} options 81 | * @param {?string} path - optional file path to set when storing the data on IPFS 82 | * @param {?string} name - optional name to set in NFT metadata 83 | * @param {?string} description - optional description to store in NFT metadata 84 | * @param {?string} owner - optional ethereum address that should own the new NFT. 85 | * If missing, the default signing address will be used. 86 | * 87 | * @typedef {object} CreateNFTResult 88 | * @property {string} tokenId - the unique ID of the new token 89 | * @property {string} ownerAddress - the ethereum address of the new token's owner 90 | * @property {object} metadata - the JSON metadata stored in IPFS and referenced by the token's metadata URI 91 | * @property {string} metadataURI - an ipfs:// URI for the NFT metadata 92 | * @property {string} metadataGatewayURL - an HTTP gateway URL for the NFT metadata 93 | * @property {string} assetURI - an ipfs:// URI for the NFT asset 94 | * @property {string} assetGatewayURL - an HTTP gateway URL for the NFT asset 95 | * 96 | * @returns {Promise} 97 | */ 98 | async createNFTFromAssetData(content, options) { 99 | // add the asset to IPFS 100 | const filePath = options.path || "asset.bin"; 101 | const basename = path.basename(filePath); 102 | 103 | // When you add an object to IPFS with a directory prefix in its path, 104 | // IPFS will create a directory structure for you. This is nice, because 105 | // it gives us URIs with descriptive filenames in them e.g. 106 | // 'ipfs://QmaNZ2FCgvBPqnxtkbToVVbK2Nes6xk5K4Ns6BsmkPucAM/cat-pic.png' instead of 107 | // 'ipfs://QmaNZ2FCgvBPqnxtkbToVVbK2Nes6xk5K4Ns6BsmkPucAM' 108 | const ipfsPath = "/nft/" + basename; 109 | const { cid: assetCid } = await this.ipfs.add( 110 | { path: ipfsPath, content }, 111 | ipfsAddOptions 112 | ); 113 | 114 | // make the NFT metadata JSON 115 | const assetURI = ensureIpfsUriPrefix(assetCid) + "/" + basename; 116 | const metadata = await this.makeNFTMetadata(assetURI, options); 117 | 118 | // add the metadata to IPFS 119 | const { cid: metadataCid } = await this.ipfs.add( 120 | { path: "/nft/metadata.json", content: JSON.stringify(metadata) }, 121 | ipfsAddOptions 122 | ); 123 | const metadataURI = ensureIpfsUriPrefix(metadataCid) + "/metadata.json"; 124 | 125 | // get the address of the token owner from options, or use the default signing address if no owner is given 126 | let ownerAddress = options.owner; 127 | if (!ownerAddress) { 128 | ownerAddress = await this.defaultOwnerAddress(); 129 | } 130 | 131 | // mint a new token referencing the metadata URI 132 | const tokenId = await this.mintToken(ownerAddress, metadataURI); 133 | 134 | // format and return the results 135 | return { 136 | tokenId, 137 | ownerAddress, 138 | metadata, 139 | assetURI, 140 | metadataURI, 141 | assetGatewayURL: makeGatewayURL(assetURI), 142 | metadataGatewayURL: makeGatewayURL(metadataURI), 143 | }; 144 | } 145 | 146 | /** 147 | * Create a new NFT from an asset file at the given path. 148 | * 149 | * @param {string} filename - the path to an image file or other asset to use 150 | * @param {object} options 151 | * @param {?string} name - optional name to set in NFT metadata 152 | * @param {?string} description - optional description to store in NFT metadata 153 | * @param {?string} owner - optional ethereum address that should own the new NFT. 154 | * If missing, the default signing address will be used. 155 | * 156 | * @returns {Promise} 157 | */ 158 | async createNFTFromAssetFile(filename, options) { 159 | const content = await fs.readFile(filename); 160 | return this.createNFTFromAssetData(content, { ...options, path: filename }); 161 | } 162 | 163 | /** 164 | * Helper to construct metadata JSON for 165 | * @param {string} assetCid - IPFS URI for the NFT asset 166 | * @param {object} options 167 | * @param {?string} name - optional name to set in NFT metadata 168 | * @param {?string} description - optional description to store in NFT metadata 169 | * @returns {object} - NFT metadata object 170 | */ 171 | async makeNFTMetadata(assetURI, options) { 172 | const { name, description } = options; 173 | assetURI = ensureIpfsUriPrefix(assetURI); 174 | return { 175 | name, 176 | description, 177 | image: assetURI, 178 | }; 179 | } 180 | 181 | ////////////////////////////////////////////// 182 | // -------- NFT Retreival 183 | ////////////////////////////////////////////// 184 | 185 | /** 186 | * Get information about an existing token. 187 | * By default, this includes the token id, owner address, metadata, and metadata URI. 188 | * To include info about when the token was created and by whom, set `opts.fetchCreationInfo` to true. 189 | * To include the full asset data (base64 encoded), set `opts.fetchAsset` to true. 190 | * 191 | * @param {string} tokenId 192 | * @param {object} opts 193 | * @param {?boolean} opts.fetchAsset - if true, asset data will be fetched from IPFS and returned in assetData (base64 encoded) 194 | * @param {?boolean} opts.fetchCreationInfo - if true, fetch historical info (creator address and block number) 195 | * 196 | * 197 | * @typedef {object} NFTInfo 198 | * @property {string} tokenId 199 | * @property {string} ownerAddress 200 | * @property {object} metadata 201 | * @property {string} metadataURI 202 | * @property {string} metadataGatewayURI 203 | * @property {string} assetURI 204 | * @property {string} assetGatewayURL 205 | * @property {?string} assetDataBase64 206 | * @property {?object} creationInfo 207 | * @property {string} creationInfo.creatorAddress 208 | * @property {number} creationInfo.blockNumber 209 | * @returns {Promise} 210 | */ 211 | async getNFT(tokenId, opts) { 212 | const { metadata, metadataURI } = await this.getNFTMetadata(tokenId); 213 | const ownerAddress = await this.getTokenOwner(tokenId); 214 | const metadataGatewayURL = makeGatewayURL(metadataURI); 215 | const nft = { 216 | tokenId, 217 | metadata, 218 | metadataURI, 219 | metadataGatewayURL, 220 | ownerAddress, 221 | }; 222 | 223 | const { fetchAsset, fetchCreationInfo } = opts || {}; 224 | if (metadata.image) { 225 | nft.assetURI = metadata.image; 226 | nft.assetGatewayURL = makeGatewayURL(metadata.image); 227 | if (fetchAsset) { 228 | nft.assetDataBase64 = await this.getIPFSBase64(metadata.image); 229 | } 230 | } 231 | 232 | if (fetchCreationInfo) { 233 | nft.creationInfo = await this.getCreationInfo(tokenId); 234 | } 235 | return nft; 236 | } 237 | 238 | /** 239 | * Fetch the NFT metadata for a given token id. 240 | * 241 | * @param tokenId - the id of an existing token 242 | * @returns {Promise<{metadata: object, metadataURI: string}>} - resolves to an object containing the metadata and 243 | * metadata URI. Fails if the token does not exist, or if fetching the data fails. 244 | */ 245 | async getNFTMetadata(tokenId) { 246 | const metadataURI = await this.contract.tokenURI(tokenId); 247 | const metadata = await this.getIPFSJSON(metadataURI); 248 | 249 | return { metadata, metadataURI }; 250 | } 251 | 252 | ////////////////////////////////////////////// 253 | // --------- Smart contract interactions 254 | ////////////////////////////////////////////// 255 | 256 | /** 257 | * Create a new NFT token that references the given metadata CID, owned by the given address. 258 | * 259 | * @param {string} ownerAddress - the ethereum address that should own the new token 260 | * @param {string} metadataURI - IPFS URI for the NFT metadata that should be associated with this token 261 | * @returns {Promise} - the ID of the new token 262 | */ 263 | async mintToken(ownerAddress, metadataURI) { 264 | // the smart contract adds an ipfs:// prefix to all URIs, so make sure it doesn't get added twice 265 | metadataURI = stripIpfsUriPrefix(metadataURI); 266 | 267 | // Call the mintToken method to issue a new token to the given address 268 | // This returns a transaction object, but the transaction hasn't been confirmed 269 | // yet, so it doesn't have our token id. 270 | const tx = await this.contract.mintToken(ownerAddress, metadataURI, { 271 | gasLimit: 500_000, 272 | value: ethers.utils.parseEther("0.08"), 273 | }); 274 | 275 | // The OpenZeppelin base ERC721 contract emits a Transfer event when a token is issued. 276 | // tx.wait() will wait until a block containing our transaction has been mined and confirmed. 277 | // The transaction receipt contains events emitted while processing the transaction. 278 | const receipt = await tx.wait(); 279 | for (const event of receipt.events) { 280 | if (event.event !== "Transfer") { 281 | console.log("ignoring unknown event type ", event.event); 282 | continue; 283 | } 284 | return event.args.tokenId.toString(); 285 | } 286 | 287 | throw new Error("unable to get token id"); 288 | } 289 | 290 | async transferToken(tokenId, toAddress) { 291 | const fromAddress = await this.getTokenOwner(tokenId); 292 | 293 | // because the base ERC721 contract has two overloaded versions of the safeTranferFrom function, 294 | // we need to refer to it by its fully qualified name. 295 | const tranferFn = 296 | this.contract["safeTransferFrom(address,address,uint256)"]; 297 | const tx = await tranferFn(fromAddress, toAddress, tokenId); 298 | 299 | // wait for the transaction to be finalized 300 | await tx.wait(); 301 | } 302 | 303 | /** 304 | * @returns {Promise} - the default signing address that should own new tokens, if no owner was specified. 305 | */ 306 | async defaultOwnerAddress() { 307 | const signers = await this.hardhat.ethers.getSigners(); 308 | return signers[0].address; 309 | } 310 | 311 | /** 312 | * Get the address that owns the given token id. 313 | * 314 | * @param {string} tokenId - the id of an existing token 315 | * @returns {Promise} - the ethereum address of the token owner. Fails if no token with the given id exists. 316 | */ 317 | async getTokenOwner(tokenId) { 318 | return this.contract.ownerOf(tokenId); 319 | } 320 | 321 | /** 322 | * Get historical information about the token. 323 | * 324 | * @param {string} tokenId - the id of an existing token 325 | * 326 | * @typedef {object} NFTCreationInfo 327 | * @property {number} blockNumber - the block height at which the token was minted 328 | * @property {string} creatorAddress - the ethereum address of the token's initial owner 329 | * 330 | * @returns {Promise} 331 | */ 332 | async getCreationInfo(tokenId) { 333 | const filter = await this.contract.filters.Transfer( 334 | null, 335 | null, 336 | BigNumber.from(tokenId) 337 | ); 338 | 339 | const logs = await this.contract.queryFilter(filter); 340 | const blockNumber = logs[0].blockNumber; 341 | const creatorAddress = logs[0].args.to; 342 | return { 343 | blockNumber, 344 | creatorAddress, 345 | }; 346 | } 347 | 348 | ////////////////////////////////////////////// 349 | // --------- IPFS helpers 350 | ////////////////////////////////////////////// 351 | 352 | /** 353 | * Get the full contents of the IPFS object identified by the given CID or URI. 354 | * 355 | * @param {string} cidOrURI - IPFS CID string or `ipfs://` style URI 356 | * @returns {Promise} - contents of the IPFS object 357 | */ 358 | 359 | async getIPFS(cidOrURI) { 360 | const cid = stripIpfsUriPrefix(cidOrURI); 361 | return new Uint8Array(await all(this.ipfs.cat(cid))); 362 | } 363 | 364 | /** 365 | * Get the contents of the IPFS object identified by the given CID or URI, and return it as a string. 366 | * 367 | * @param {string} cidOrURI - IPFS CID string or `ipfs://` style URI 368 | * @returns {Promise} - the contents of the IPFS object as a string 369 | */ 370 | async getIPFSString(cidOrURI) { 371 | const bytes = await this.getIPFS(cidOrURI); 372 | return new Uint8Array(bytes); 373 | } 374 | 375 | /** 376 | * Get the full contents of the IPFS object identified by the given CID or URI, and return it as a base64 encoded string. 377 | * 378 | * @param {string} cidOrURI - IPFS CID string or `ipfs://` style URI 379 | * @returns {Promise} - contents of the IPFS object, encoded to base64 380 | */ 381 | async getIPFSBase64(cidOrURI) { 382 | const bytes = await this.getIPFS(cidOrURI); 383 | return Uint8Array(bytes, "base64"); 384 | } 385 | 386 | /** 387 | * Get the contents of the IPFS object identified by the given CID or URI, and parse it as JSON, returning the parsed object. 388 | * 389 | * @param {string} cidOrURI - IPFS CID string or `ipfs://` style URI 390 | * @returns {Promise} - contents of the IPFS object, as a javascript object (or array, etc depending on what was stored). Fails if the content isn't valid JSON. 391 | */ 392 | async getIPFSJSON(cidOrURI) { 393 | const str = await this.getIPFSString(cidOrURI); 394 | return JSON.parse(str); 395 | } 396 | 397 | ////////////////////////////////////////////// 398 | // -------- Pinning to remote services 399 | ////////////////////////////////////////////// 400 | 401 | /** 402 | * Pins all IPFS data associated with the given tokend id to the remote pinning service. 403 | * 404 | * @param {string} tokenId - the ID of an NFT that was previously minted. 405 | * @returns {Promise<{assetURI: string, metadataURI: string}>} - the IPFS asset and metadata uris that were pinned. 406 | * Fails if no token with the given id exists, or if pinning fails. 407 | */ 408 | async pinTokenData(tokenId) { 409 | const { metadata, metadataURI } = await this.getNFTMetadata(tokenId); 410 | const { image: assetURI } = metadata; 411 | 412 | console.log(`Pinning asset data (${assetURI}) for token id ${tokenId}....`); 413 | await this.pin(assetURI); 414 | 415 | console.log(`Pinning metadata (${metadataURI}) for token id ${tokenId}...`); 416 | await this.pin(metadataURI); 417 | 418 | return { assetURI, metadataURI }; 419 | } 420 | 421 | /** 422 | * Request that the remote pinning service pin the given CID or ipfs URI. 423 | * 424 | * @param {string} cidOrURI - a CID or ipfs:// URI 425 | * @returns {Promise} 426 | */ 427 | async pin(cidOrURI) { 428 | const cid = extractCID(cidOrURI); 429 | 430 | // Make sure IPFS is set up to use our preferred pinning service. 431 | await this._configurePinningService(); 432 | 433 | // Check if we've already pinned this CID to avoid a "duplicate pin" error. 434 | const pinned = await this.isPinned(cid); 435 | if (pinned) { 436 | return; 437 | } 438 | 439 | // Ask the remote service to pin the content. 440 | // Behind the scenes, this will cause the pinning service to connect to our local IPFS node 441 | // and fetch the data using Bitswap, IPFS's transfer protocol. 442 | await this.ipfs.pin.remote.add(cid, { 443 | service: config.pinningService.name, 444 | }); 445 | } 446 | 447 | /** 448 | * Check if a cid is already pinned. 449 | * 450 | * @param {string|CID} cid 451 | * @returns {Promise} - true if the pinning service has already pinned the given cid 452 | */ 453 | async isPinned(cid) { 454 | if (typeof cid === "string") { 455 | cid = new CID(cid); 456 | } 457 | 458 | const opts = { 459 | service: config.pinningService.name, 460 | cid: [cid], // ls expects an array of cids 461 | }; 462 | for await (const result of this.ipfs.pin.remote.ls(opts)) { 463 | return true; 464 | } 465 | return false; 466 | } 467 | 468 | /** 469 | * Configure IPFS to use the remote pinning service from our config. 470 | * 471 | * @private 472 | */ 473 | async _configurePinningService() { 474 | if (!config.pinningService) { 475 | throw new Error( 476 | `No pinningService set up in minty config. Unable to pin.` 477 | ); 478 | } 479 | 480 | // check if the service has already been added to js-ipfs 481 | for (const svc of await this.ipfs.pin.remote.service.ls()) { 482 | if (svc.service === config.pinningService.name) { 483 | // service is already configured, no need to do anything 484 | return; 485 | } 486 | } 487 | 488 | // add the service to IPFS 489 | const { name, endpoint, key } = config.pinningService; 490 | if (!name) { 491 | throw new Error("No name configured for pinning service"); 492 | } 493 | if (!endpoint) { 494 | throw new Error(`No endpoint configured for pinning service ${name}`); 495 | } 496 | if (!key) { 497 | throw new Error( 498 | `No key configured for pinning service ${name}.` + 499 | `If the config references an environment variable, e.g. '$$PINATA_API_TOKEN', ` + 500 | `make sure that the variable is defined.` 501 | ); 502 | } 503 | await this.ipfs.pin.remote.service.add(name, { endpoint, key }); 504 | } 505 | } 506 | 507 | ////////////////////////////////////////////// 508 | // -------- URI helpers 509 | ////////////////////////////////////////////// 510 | 511 | /** 512 | * @param {string} cidOrURI either a CID string, or a URI string of the form `ipfs://${cid}` 513 | * @returns the input string with the `ipfs://` prefix stripped off 514 | */ 515 | function stripIpfsUriPrefix(cidOrURI) { 516 | if (cidOrURI.startsWith("ipfs://")) { 517 | return cidOrURI.slice("ipfs://".length); 518 | } 519 | return cidOrURI; 520 | } 521 | 522 | function ensureIpfsUriPrefix(cidOrURI) { 523 | let uri = cidOrURI.toString(); 524 | if (!uri.startsWith("ipfs://")) { 525 | uri = "ipfs://" + cidOrURI; 526 | } 527 | // Avoid the Nyan Cat bug (https://github.com/ipfs/go-ipfs/pull/7930) 528 | if (uri.startsWith("ipfs://ipfs/")) { 529 | uri = uri.replace("ipfs://ipfs/", "ipfs://"); 530 | } 531 | return uri; 532 | } 533 | 534 | /** 535 | * Return an HTTP gateway URL for the given IPFS object. 536 | * @param {string} ipfsURI - an ipfs:// uri or CID string 537 | * @returns - an HTTP url to view the IPFS object on the configured gateway. 538 | */ 539 | function makeGatewayURL(ipfsURI) { 540 | return config.ipfsGatewayUrl + "/" + stripIpfsUriPrefix(ipfsURI); 541 | } 542 | 543 | /** 544 | * 545 | * @param {string} cidOrURI - an ipfs:// URI or CID string 546 | * @returns {CID} a CID for the root of the IPFS path 547 | */ 548 | function extractCID(cidOrURI) { 549 | // remove the ipfs:// prefix, split on '/' and return first path component (root CID) 550 | const cidString = stripIpfsUriPrefix(cidOrURI).split("/")[0]; 551 | return new CID(cidString); 552 | } 553 | 554 | ////////////////////////////////////////////// 555 | // -------- Exports 556 | ////////////////////////////////////////////// 557 | 558 | module.exports = { 559 | MakeMinty, 560 | }; --------------------------------------------------------------------------------