As of this writing, the overall cryptocurrency sector is undergoing a major downturn in which about two-third of the average blockchain’s market cap has evaporated since early April. This might not be the best time to try make anyone excited about launching NFTs on any blockchain. Nevertheless, volatility of cryptocurrencies has always been a known phenomenon. From a technological perspective, it’s much less of a concern than how well-designed the underlying blockchain is in terms of security, decentralization and scalability.
Many popular blockchain platforms out there are offering their own open-standard crypto tokens, but so far the most prominent crypto tokens, fungible or non-fungible, are Ethereum-based. For instance, the NFTs we minted on Avalanche‘s Fuji testnet in a previous blog post are Ethereum-based and ERC-721 compliant.
Given Ethereum’s large market share, dApp developers generally prefer having their NFTs transacting on Ethereum main chain or Ethereum-compatible chains like Polygon and Avalanche’s C-Chain, yet many others opt to pick incompatible chains as their NFT development/trading platforms.
Choosing a blockchain for NFT development
Use cases of NFT are mostly about provenance of authenticity. To ensure that the NFT related transactions to be verifiable at any point of time, the perpetual availability of the blockchain in which the transaction history reside is critical. Though not often, hard forks do happen, consequently rendering historical transactions forking off the main chain. That’s highly undesirable. Some blockchains such as Algorand and Polkadot tout that their chains are by-design “forkless”, which does provide an edge over competitors.
Another factor critical for transacting NFTs on a blockchain is low latency in consensually finalizing transactions. That latency is generally referred to as finality. Given that auctions are commonly time-sensitive events for trading of NFTs, a long delay is obviously ill-suited. Chains like Avalanche and Algorand are able to keep the finality under 5 seconds which is much shorter compared to 10+ minutes required on other blockchains like Cardano or Ethereum.
Algorand and IPFS
Launched 3 years ago in June 2019, Algorand is a layer-1 blockchain with a focus on being highly decentralized, scalable and secured with low transaction cost. Its low latency (i.e. finality) along with a design emphasis in running the blockchain with a forkless operational model makes it an appealing chain for transacting and securing NFTs.
In this blog post, we’re going to create a simple dApp in JavaScript to mint a NFT on the Algorand Testnet for a digital asset pinned to IPFS. IPFS, short for InterPlanetary File System, is a decentralized peer-to-peer network aimed to serve as a single resilient global network for storing and sharing files.
Algorand NFT
Algorand comes with its standard asset class, ASA (Algorand Standard Assets), which can be used for representing a variety of customizable digital assets. A typical NFT can be defined configuratively as an ASA by assigning asset parameters with values that conform to Algorand’s NFT specifications.
The two common Algorand specs for NFTs are ARC-3 and ARC-69. A main difference between the two specs is that ARC-69 asset data is kept on-chain whereas ARC-3’s isn’t. We’ll be minting an ARC-3 NFT with the digital asset stored on IPFS.
Contrary to the previous NFT development exercise on Avalanche that leverages a rich-UI stack (i.e. Scaffold-ETH), this is going to be a barebone proof-of-concept with core focus on how to programmatically create a NFT for a digital asset (an digital image) pinned to IPFS. No web frontend UI.
Algorand SDKs
Algorand offers SDKs for a few programming languages including JavaScript, Python, Go and Java. We’ll be using the JavaScript SDK. The official developer website provides tutorials for various dApp use cases and one of them is exactly about launching ARC-3 NFT for assets on IPFS. Unfortunately, even though the tutorial is only about 6 months old it no longer works due to some of its code already being obsolete.
It’s apparently a result of the rapidly evolving SDK code — a frustrating but common problem for developers to have to constantly play catch-up game with backward incompatible APIs evolving at a breakneck pace. For example, retrieval of user-level information is no longer supported by the latest Algorand SDK client (algod client
), but no where could I find any tutorials doing it otherwise. Presumably for scalability, it turns out such queries are now delegated to an Algorand indexer which runs as an independent process backed by a PostgreSQL compatible database.
Given the doubt of the demo code on the Algorand website being out of date, I find it more straight forward (though a little tedious) to pick up the how-to’s by directly digging into the SDK source code js-algorand-sdk. For instance, one could quickly skim through the method signature and implementation logic of algod client method pendingTransactionInformation
from the corresponding source or indexer
method lookupAccountByID
from indexer’s source for their exact tech specs.
Create a NPM project with dependencies
Minimal requirements for this Algorand NFT development exercise include the following:
- Node.js installed with NPM
- An account at Pinata, an IPFS pinning service
- An Algorand compatible crypto wallet (Pera is preferred for the availability of a “developer” mode)
First, create a subdirectory as the project root. For example:
$ mkdir ~/algorand/algo-nft-ipfs/ $ cd ~/algorand/algo-nft-ipfs/
Next, create dependency file package.json
with content like below:
{ "name": "algo-nft-ipfs", "version": "1.0.0", "description": "Algorand NFT with asset pinned to IPFS", "scripts": { "mint": "node algo-nft-ipfs.js" }, "dependencies": { "@algonaut/algo-validation-agent": "latest", "@pinata/sdk": "latest", "algosdk": "latest", "bs58": "latest", "dotenv": "latest", "ipfs-core": "latest", "ipfs-http-client": "latest", "node-base64-image": "latest" }, "devDependencies": { "nodemon": "latest", "parcel-bundler": "latest" }, "keywords": [] }
Install the NPM package:
$ npm install
Create file “.env” for keeping private keys
Besides the SDKs for Pinata and Algorand, dotenv is also included in the package.json
dependency file, allowing variables such as NFT recipient’s wallet mnemonic, algod client
/indexer
URLs (for Algorand Testnet) and Pinata API keys, to be kept in file .env
in the filesystem.
mnemonic = "" algodClientUrl = "https://node.testnet.algoexplorerapi.io" algodClientPort = "" algodClientToken = "" indexerUrl = "https://algoindexer.testnet.algoexplorerapi.io" indexerPort = "" indexerToken = "" pinataApiKey = " " pinataApiSecret = " "
Note that Algorand uses a 25-word mnemonic with the 25th word derived from the checksum out of selected words within the 24-word BIP39-compliant mnemonic. Many blockchains simply use 12-word BIP39 mnemonics.
ARC-3 NFT metadata
Next, we create file assetMetadata.js
for storing algorand ARC-3 specific metadata:
module.exports = { arc3MetadataJson: { "name": "", "description": "", "image": "ipfs://", "image_integrity": "sha256-", "image_mimetype": "", "external_url": "", "external_url_integrity": "", "external_url_mimetype": "", "animation_url": "", "animation_url_integrity": "sha256-", "animation_url_mimetype": "", "properties": { "file_url": "", "file_url_integrity": "", "file_url_mimetype": "", } } }
Main application logic
The main function createNft
does a few things:
- Create an account object from the wallet mnemonic stored in file
.env
- Create an digital asset (an image) pinned to IPFS
- Create an ARC-3 compliant NFT associated with the pinned asset
const createNft = async () => { try { let account = createAccount(); console.log("Press any key when the account is funded ..."); await keypress(); const asset = await createAssetOnIpfs(); const { assetID } = await createArc3Asset(asset, account); } catch (err) { console.log("err", err); }; process.exit(); };
Account object creation
Function createAccount
is self explanatory. It retrieves the wallet mnemonic from file .env
, derives from it the secret key and wallet address (public key) and display a reminder to make sure the account is funded with Algorand Testnet tokens as fees for transactions.
const createAccount = () => { try { const mnemonic = process.env.mnemonic const account = algosdk.mnemonicToSecretKey(mnemonic); console.log("Derived account address = " + account.addr); console.log("To add funds to the account, visit https://dispenser.testnet.aws.algodev.network/?account=" + account.addr); return account; } catch (err) { console.log("err", err); } };
Pinning a digital asset to IPFS
Function createAssetOnIpfs
is responsible for creating and pinning a digital asset to IPFS. This is where the asset attributes including source file path, description, MIME type (e.g. image/png, video/mp4
) will be provided. In this example a ninja smiley JPEG under the project root is being used as a placeholder. Simply substitute it with your favorite image, video, etc.
const createAssetOnIpfs = async () => { return await pinata.testAuthentication().then((res) => { console.log('Pinata test authentication: ', res); return assetPinnedToIpfs( 'smiley-ninja-896x896.jpg', 'image/jpeg', 'Ninja Smiley', 'Ninja Smiley 896x896 JPEG image pinned to IPFS' ); }).catch((err) => { return console.log(err); }); }
The actual pinning work is performed by function assetPinnedToIpfs
which returns identifying IPFS URL for the digital asset’s metadata.
const assetPinnedToIpfs = async (nftFilePath, mimeType, assetName, assetDesc) => { const nftFile = fs.createReadStream(nftFilePath); ... const pinMeta = { pinataMetadata: { name: assetName, ... }, pinataOptions: { cidVersion: 0 } }; const resultFile = await pinata.pinFileToIPFS(nftFile, pinMeta); let metadata = assetMetadata.arc3MetadataJson; const integrity = ipfsHash(resultFile.IpfsHash); metadata.name = `${assetName}@arc3`; metadata.description = assetDesc; metadata.image = `ipfs://${resultFile.IpfsHash}`; metadata.image_integrity = `${integrity.cidBase64}`; metadata.image_mimetype = mimeType; metadata.properties = ... ... const resultMeta = await pinata.pinJSONToIPFS(metadata, pinMeta); const metaIntegrity = ipfsHash(resultMeta.IpfsHash); return { name: `${assetName}@arc3`, url: `ipfs://${resultMeta.IpfsHash}`, metadata: metaIntegrity.cidUint8Arr, integrity: metaIntegrity.cidBase64 }; };
Creating an Alogrand ARC-3 NFT
Function createNft
is just an interfacing wrapper of the createArc3Asset
function that does the actual work of initiating and signing the transactions on the Algorand Testnet for creating the ARC-3 NFT associated with the pinned asset using algod client
.
const createArc3Asset = async (asset, account) => { (async () => { let acct = await indexerClient.lookupAccountByID(account.addr).do(); console.log("Account Address: " + acct['account']['address']); ... })().catch(e => { console.error(e); console.trace(); }); const txParams = await algodClient.getTransactionParams().do(); const txn = algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject({ from: account.addr, total: 1, decimals: 0, ... unitName: 'nft', assetName: asset.name, assetURL: asset.url, assetMetadataHash: new Uint8Array(asset.metadata), suggestedParams: txParams }); const rawSignedTxn = txn.signTxn(account.sk); const tx = await algodClient.sendRawTransaction(rawSignedTxn).do(); const confirmedTxn = await waitForConfirmation(tx.txId); const txInfo = await algodClient.pendingTransactionInformation(tx.txId).do(); const assetID = txInfo["asset-index"]; console.log('Account ', account.addr, ' has created ARC3 compliant NFT with asset ID', assetID); console.log(`Check it out at https://testnet.algoexplorer.io/asset/${assetID}`); return { assetID }; }
Note that element values total:1
and decimals:0
are for ensuring uniqueness of the NFT. Also worth noting is that Algorand SDK provides a waitForConfirmation()
function for awaiting transaction confirmation for a specified number of rounds:
const confirmedTxn = await algosdk.waitForConfirmation(algodClient, txId, waitRounds);
However, for some unknown reason, it doesn’t seem to work with a fixed waitRounds
, thus a custom function seen in a demo app on Algorand’s website is being used instead. The custom code simply verifies returned value from algod client method pendingTransactionInformation(txId)
in a loop.
Putting everything together
For simplicity, the above code logic is all put in a single JavaScript script algo-nft-ipfs.js
under the project root.
const fs = require('fs'); const path = require('path'); const algosdk = require('algosdk'); const bs58 = require('bs58'); require('dotenv').config() const assetMetadata = require('./assetMetadata'); const algodClient = new algosdk.Algodv2( process.env.algodClientToken, process.env.algodClientUrl, process.env.algodClientPort ); const indexerClient = new algosdk.Indexer( process.env.indexerToken, process.env.indexerUrl, process.env.indexerPort ); const pinataApiKey = process.env.pinataApiKey; const pinataApiSecret = process.env.pinataApiSecret; const pinataSdk = require('@pinata/sdk'); const pinata = pinataSdk(pinataApiKey, pinataApiSecret); const keypress = async () => { process.stdin.setRawMode(true); return new Promise(resolve => process.stdin.once('data', () => { process.stdin.setRawMode(false) resolve() })); }; const waitForConfirmation = async (txId) => { const status = await algodClient.status().do(); let lastRound = status["last-round"]; let txInfo = null; while (true) { txInfo = await algodClient.pendingTransactionInformation(txId).do(); if (txInfo["confirmed-round"] !== null && txInfo["confirmed-round"] > 0) { console.log("Transaction " + txId + " confirmed in round " + txInfo["confirmed-round"]); break; } lastRound ++; await algodClient.statusAfterBlock(lastRound).do(); } return txInfo; } const createAccount = () => { try { const mnemonic = process.env.mnemonic const account = algosdk.mnemonicToSecretKey(mnemonic); console.log("Derived account address = " + account.addr); console.log("To add funds to the account, visit https://dispenser.testnet.aws.algodev.network/?account=" + account.addr); return account; } catch (err) { console.log("err", err); } }; const ipfsHash = (cid) => { const cidUint8Arr = bs58.decode(cid).slice(2); const cidBase64 = cidUint8Arr.toString('base64'); return { cidUint8Arr, cidBase64 }; }; const assetPinnedToIpfs = async (nftFilePath, mimeType, assetName, assetDesc) => { const nftFile = fs.createReadStream(nftFilePath); const nftFileName = nftFilePath.split('/').pop(); const properties = { "file_url": nftFileName, "file_url_integrity": "", "file_url_mimetype": mimeType }; const pinMeta = { pinataMetadata: { name: assetName, keyvalues: { "url": nftFileName, "mimetype": mimeType } }, pinataOptions: { cidVersion: 0 } }; const resultFile = await pinata.pinFileToIPFS(nftFile, pinMeta); console.log('Asset pinned to IPFS via Pinata: ', resultFile); let metadata = assetMetadata.arc3MetadataJson; const integrity = ipfsHash(resultFile.IpfsHash); metadata.name = `${assetName}@arc3`; metadata.description = assetDesc; metadata.image = `ipfs://${resultFile.IpfsHash}`; metadata.image_integrity = `${integrity.cidBase64}`; metadata.image_mimetype = mimeType; metadata.properties = properties; metadata.properties.file_url = `https://ipfs.io/ipfs/${resultFile.IpfsHash}`; metadata.properties.file_url_integrity = `${integrity.cidBase64}`; console.log('Algorand NFT-IPFS metadata: ', metadata); const resultMeta = await pinata.pinJSONToIPFS(metadata, pinMeta); const metaIntegrity = ipfsHash(resultMeta.IpfsHash); console.log('Asset metadata pinned to IPFS via Pinata: ', resultMeta); return { name: `${assetName}@arc3`, url: `ipfs://${resultMeta.IpfsHash}`, metadata: metaIntegrity.cidUint8Arr, integrity: metaIntegrity.cidBase64 }; }; const createAssetOnIpfs = async () => { return await pinata.testAuthentication().then((res) => { console.log('Pinata test authentication: ', res); return assetPinnedToIpfs( 'smiley-ninja-896x896.jpg', 'image/jpeg', 'Ninja Smiley', 'Ninja Smiley 896x896 JPEG image pinned to IPFS' ); }).catch((err) => { return console.log(err); }); } const createArc3Asset = async (asset, account) => { (async () => { let acct = await indexerClient.lookupAccountByID(account.addr).do(); console.log("Account Address: " + acct['account']['address']); console.log(" Amount: " + acct['account']['amount']); console.log(" Rewards: " + acct['account']['rewards']); console.log(" Created Assets: " + acct['account']['total-created-assets']); console.log(" Current Round: " + acct['current-round']); })().catch(e => { console.error(e); console.trace(); }); const txParams = await algodClient.getTransactionParams().do(); const txn = algosdk.makeAssetCreateTxnWithSuggestedParamsFromObject({ from: account.addr, total: 1, decimals: 0, defaultFrozen: false, manager: account.addr, reserve: undefined, freeze: undefined, clawback: undefined, unitName: 'nft', assetName: asset.name, assetURL: asset.url, assetMetadataHash: new Uint8Array(asset.metadata), suggestedParams: txParams }); const rawSignedTxn = txn.signTxn(account.sk); const tx = await algodClient.sendRawTransaction(rawSignedTxn).do(); // const confirmedTxn = await algosdk.waitForConfirmation(algodClient, tx, 4); // /* Error: Transaction not confirmed after 4 rounds */ const confirmedTxn = await waitForConfirmation(tx.txId); const txInfo = await algodClient.pendingTransactionInformation(tx.txId).do(); const assetID = txInfo["asset-index"]; console.log('Account ', account.addr, ' has created ARC3 compliant NFT with asset ID', assetID); console.log(`Check it out at https://testnet.algoexplorer.io/asset/${assetID}`); return { assetID }; } const createNft = async () => { try { let account = createAccount(); console.log("Press any key when the account is funded ..."); await keypress(); const asset = await createAssetOnIpfs(); const { assetID } = await createArc3Asset(asset, account); } catch (err) { console.log("err", err); }; process.exit(); }; createNft();
Minting the Algorand NFT
To mint the ARC-3 compliant NFT:
$ npm run mint
Upon successful minting of the NFT, you’ll see messages similar to the following with a reminder for where to look up for details about the NFT and its associated transactions on the Algorand Testnet:
Accounthas created ARC3 compliant NFT with asset ID: Check it out at https://testnet.algoexplorer.io/asset/
Verifying …
From the algoexplorer.io
website, the URL of the IPFS metadata file for the NFT should look like below:
ipfs://
Value
should match the hash value of the pinned asset metadata file under your Pinata account and should be viewable at:
https://gateway.pinata.cloud/ipfs/
There are a few Algorand compatible wallets with Pera being the one created by the Algorand’s development team. For developers, Pera has a convenient option for switching between the Algorand Mainnet and Testnet. To verify the receipt of the NFT, simply switch to Algorand Testnet (under Settings > Developer Settings > Node Settings
).
Here’s what the received NFT in a Pera wallet would look like:
From within Pera Explorer: