In this post, I want to explore two things: How to create an on-chain NFT and how to use a hardware wallet for the minting instead of creating (and needing to fund) a lot of file-based keys as in the usual guides.
On-Chain NFTs
Usually NFTs reference an off-chain location – a URI reachable by HTTP(S), IPFS, or Arweave, for example – where the content resides. If this location ceases to be reachable, the content of the NFT will no longer be available. The NFT continues to exist on the blockchain, can still be transferred from address to address, but we cannot see the image, watch the video, or hear the song that it refers to, anymore.
For on-chain NFTs, we use data:
URIs, which do not reference an external resource, but directly contain the content in an encoded form. This way, the content of the NFT resides on the blockchain inside the metadata and remains available as long as the blockchain keeps running.
Data URIs can contain arbitrary file types, but it is quite common that a combination of HTML, CSS, and Javascript is used to create (inter)active NFTs. The art is to keep the content small enough that it can still be embedded into the minting transaction, which is restricted to 16 KiB at the moment. Examples of such on-chain NFTs include Stellar Hood and Mesmerizer.
(If we talk about the far future, we would also have to consider that maybe clients look way different then. It is possible that it won’t be usual anymore that they can interpret the content types that we put into our NFTs, that HTML/CSS/JS gets outdated, that SVG/JPG/PNG become as exotic as IFF or Koala Painter are today. It will probably not become impossible to view them, since knowledge about these technologies is very wide-spread, but it may happen that not every computer can readily display them without effort, anymore. This would be a problem for off-chain as well as on-chain NFTs.)
The Assets
The example NFT will be something like a business card with a logo that on click is exchanged for a page with a headline and a QR code (pointing to my forum profile). As assets we need the logo, the QR code, and the font for the headline.
My logo is a hand-crafted SVG, the source of which is:
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1600px" height="1600px" viewBox="0 0 1600 1600"
xmlns="http://www.w3.org/2000/svg">
<style>
#b {
fill: #162633;
}
#s {
stroke: #446987;
}
#h {
stroke: #bcdaf8;
}
path {
fill: none;
stroke-width: 25;
stroke-linecap: round;
stroke-linejoin: round;
}
</style>
<rect id="b" width="1600" height="1600" x="0" y="0" />
<path id="s"
d="M 100 800 A 700 700 0 0 1 1500 800 A 650 650 0 0 1 200 800
A 600 600 0 0 1 1400 800 A 550 550 0 0 1 300 800
A 500 500 0 0 1 1300 800 A 450 450 0 0 1 400 800
A 400 400 0 0 1 1200 800 A 350 350 0 0 1 500 800
A 300 300 0 0 1 1100 800 A 250 250 0 0 1 600 800
A 200 200 0 0 1 1000 800 A 150 150 0 0 1 700 800
A 100 100 0 0 1 900 800 A 50 50 0 0 1 800 800" />
<path id="h"
d="M 800 100 L 1104 1431 L 253 364 L 1482 956
L 118 956 L 1347 364 L 496 1431 Z" />
</svg>
I have used https://www.svgminify.com/ (which does a good job in minimising among other things the paths by using different ways of expressing the coordinates of the next point on the path) and some manual edits afterwards to minify this from 1199 bytes to 665 bytes.
In the HTML, I will use the SVG code directly, but for using this as the preview image of the NFT, I also want to have the Base64-encoded data URI:
$ (echo -n 'data:image/svg+xml;base64,'; base64 -w 0 HeptaLogo-minimal.svg) > HeptaLogo-minimal.base64
$ cat HeptaLogo-minimal.base64
data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyB3aWR0aD0iMTYwMCIgaGVpZ2h0PSIxNjAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxzdHlsZT4jYntmaWxsOiMxNjI2MzN9I3N7c3Ryb2tlOiM0NDY5ODd9I2h7c3Ryb2tlOiNiY2RhZjh9cGF0aHtmaWxsOm5vbmU7c3Ryb2tlLXdpZHRoOjI1O3N0cm9rZS1saW5lY2FwOnJvdW5kO3N0cm9rZS1saW5lam9pbjpyb3VuZH08L3N0eWxlPjxyZWN0IGlkPSJiIiB3aWR0aD0iMTYwMCIgaGVpZ2h0PSIxNjAwIi8+PHBhdGggaWQ9InMiIGQ9Im0xMDAgODAwYTcwMCA3MDAgMCAwIDEgMTQwMCAwIDY1MCA2NTAgMCAwIDEtMTMwMCAwIDYwMCA2MDAgMCAwIDEgMTIwMCAwIDU1MCA1NTAgMCAwIDEtMTEwMCAwIDUwMCA1MDAgMCAwIDEgMWUzIDAgNDUwIDQ1MCAwIDAgMS05MDAgMCA0MDAgNDAwIDAgMCAxIDgwMCAwIDM1MCAzNTAgMCAwIDEtNzAwIDAgMzAwIDMwMCAwIDAgMSA2MDAgMCAyNTAgMjUwIDAgMCAxLTUwMCAwIDIwMCAyMDAgMCAwIDEgNDAwIDAgMTUwIDE1MCAwIDAgMS0zMDAgMCAxMDAgMTAwIDAgMCAxIDIwMCAwIDUwIDUwIDAgMCAxLTEwMCAwIi8+PHBhdGggaWQ9ImgiIGQ9Im04MDAgMTAwIDMwNCAxMzMxLTg1MS0xMDY3IDEyMjkgNTkyaC0xMzY0bDEyMjktNTkyLTg1MSAxMDY3eiIvPjwvc3ZnPgo=
(This URI can just be copied into the address bar of a browser and we can see the logo.)
For the QR code, I first also used SVG, but, since QR codes consist of lots of small squares, this is not the most efficient file format for them. In the end, I generated a PNG file on https://www.qrcode-monkey.com/ and edited it with Gimp to remove the border and choose some options (saving it with indexed colours – it only has two, not embedding any metadata) resulting in a 627 byte file.
Since, we want to later embed this into the HTML code, I’m again converting it to a data URI:
$ (echo -n 'data:image/png;base64,'; base64 -w 0 QR-CardanoForum.png) > QR-CardanoForum.base64
$ cat QR-CardanoForum.base64
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQUAAAEFAgMAAACX+BoCAAAADFBMVEUWJjNIXW9xiZ+82vhw6NWbAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH5ggMCicIr+YZxwAAAfpJREFUeNrt28tRwzAQgGGBC/CdJijBLdIGVKFSKCEFZFgmOF7vy49wzPw6CWJ9vuyspJXcmm+DiPt7FHH/meTSDhoExL29fWn7WIl7507cRg5L52Ud8Dk//C7avk8Rr+uAH4gnJi5L5P0RIv2QWF4DAfEA4QaIXFu7oePcEQiIfxPzAFnC1LcJAuIBws+pcwi2JRYVvRriYFqGeAIirsEPibwGzzsBN4PmzpnNBASEpKZTadgSXKuHb4ELAVERGnA6g4af7bazSoUNAmKHsFvKEKZS5zv9CQJim9CdZHORp7HoXtPCkq5DQJSEyXculw1uk+kImxxDyoSAkHAGlBb2V9MxP2ng9vQaCIhAVKVVHallC3GpUBNfmlMhIGIshvqFzXcuise0gIOA2CVMp2+XLfr6Gn0YAuKAcNPkYDaZZktgbuqMEBD7RLdli+PdpnmmQUBsE9XEaeZUSYlvDOs2CIiacGt6vxQLRYqYEyEgDohennBrtULSyVAL5TMIiJrIJ9zuVr2/FT0VxVYIiGmvCF8NMAeTl+LM0q7bICASkb/omdbSqqTr9eI2mVsff0BA7H65MaWVXAsHk/PDEBBniDrfSVq3GRQC4jzhb1Hkor/Y6xQQEDURP/7o4WA7Z8BwiwLiCYn8Rc/DRLUTGENFTBIhobIPAeGJX4h4Mcgb+NL9AAAAAElFTkSuQmCC
In order to use a custom font for the headline, I have used https://www.fontsquirrel.com/tools/webfont-generator and selected in “Expert” mode that I only want the WOFF format, that I want a custom subset with only the characters needed for my headline, and that I want Base64-encoded version embedded in CSS. From the generated archive, I only need the snippet in stylesheet.css
:
@font-face {
font-family: 'senextrabold';
src: url(data:application/font-woff;charset=utf-8;base64,[…]) format('woff');
font-weight: normal;
font-style: normal;
}
The HTML
Putting all of these together, I have created the following HTML code:
<!doctype html>
<html lang=en>
<meta charset=utf-8>
<title>HeptaSean's Card</title>
<style>
@font-face{font-family:'Sen';src:url(data:application/font-woff;charset=utf-8;base64,[…taken from FontSquirrel's stylesheet.css…])format('woff')}
body{background:#bcdaf8;color:#162633;margin:0}
div{width:100%;height:100vh;overflow:hidden;text-align:center}
#b{fill:#162633}
#s{stroke:#446987}
#h{stroke:#bcdaf8}
path{fill:none;stroke-width:25;stroke-linecap:round;stroke-linejoin:round}
h1{font-family:'Sen'}
img{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}
</style>
<script>
function q(){document.getElementById('l').style.display='none';
document.getElementById('q').style.display='block'}
function l(){document.getElementById('q').style.display='none';
document.getElementById('l').style.display='block'}
</script>
<body>
<div id=l onclick=q()>
<svg width="100%" height="100%" viewbox="0 0 1600 1600" xmlns="http://www.w3.org/2000/svg">
[…taken from HeptaLogo-minimal.svg…]
</svg>
</div>
<div id=q onclick=l() style=display:none>
<h1>HeptaSean</h1>
<a href=https://forum.cardano.org/u/heptasean onclick=event.stopPropagation()>
<img src="data:image/png;base64,[…taken from QR-CardanoForum.base64…]" alt="">
</a>
</div>
The two pages for the logo and the QR code are div
s with IDs l
and q
and the – quite silly – Javascript functions q()
and l()
switch between them and are called on clicks on the pages.
One thing to note: Clients presenting your NFT will typically show it in an iframe
, where they decide the size. Giving absolute sizes for div
s (which I first tried) will, hence, not work nicely. If the iframe
is larger, the NFT will be in the top-left corner, if it is smaller, it will get scrollbars. Setting the div
s to 100%
width and 100vh
height and then letting the childs fill this space or centering them in it, seems to work okayish.
The code is already quite minimal – omitting all unnecessary quotes around attribute values, omitting unrequired closing tags, omitting some of the unrequired white space – but I have kept some newlines and indentation to keep it mostly readable.
However, the validator https://validator.w3.org/nu/#file of the W3C still is happy with this and does not give a single error.
We can get the size of the HTML further down from 5427 bytes to 5078 bytes by removing the remaining whitespace (more than half of the size is the custom font subset):
$ cat HeptaCard.html | tr '\n' ' ' | sed -e 's/>[[:space:]]\+/>/g' -e 's/;[[:space:]]\+/;/g' -e 's/}[[:space:]]\+/}/g' > HeptaCard-minimal.html
(This removes all white space after >
, ;
, and }
. This might not be safe for all HTML files, but for my example it is.)
Finally, we create a Base64-encoded data URI for the whole HTML file (which, by the way, contains some of our previous Base64-encoded data URIs):
$ (echo -n 'data:text/html;base64,'; base64 -w 0 HeptaCard-minimal.html) > HeptaCard-minimal.base64
The Metadata
Now, we need to put this into a metadata.json
file according to CIP 25. (I am still using version 1, since version 2 does not seem to be widely supported, yet.)
Strings in Cardano metadata are restricted to be at most 64 bytes long. So, we have to turn our data URIs into arrays of strings of at most 64 bytes:
$ fold -w 64 HeptaLogo-minimal.base64
data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyB3aW
[…]
Y3eiIvPjwvc3ZnPgo=
$ fold -w 64 HeptaCard-minimal.base64
data:text/html;base64,PCFkb2N0eXBlIGh0bWw+PGh0bWwgbGFuZz1lbj48bW
[…]
48L2Rpdj4=
We can then create the metadata.json
:
{
"721": {
"00000000000000000000000000000000000000000000000000000000": {
"HeptaCard": {
"name": "HeptaSean's Card",
"mediaType": "image/svg+xml",
"image": [
"data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+PHN2ZyB3aW",
[…]
"Y3eiIvPjwvc3ZnPgo="
],
"files": [
{
"mediaType": "text/html",
"src": [
"data:text/html;base64,PCFkb2N0eXBlIGh0bWw+PGh0bWwgbGFuZz1lbj48bW",
[…]
"48L2Rpdj4="
]
}
]
}
}
}
}
Testing this metadata file in https://pool.pm/test/metadata looks good:
The all-zero policy ID still has to be replaced with the real one, which we will find in the minting process.
Minting with a Hardware Wallet
Most NFT minting guides – like https://developers.cardano.org/docs/native-tokens/minting-nfts/ – advise to generate keys with cardano-cli
. But then we have to take care of our secret key files and we have to send funds to their addresses to cover the fees and minimal accompanying ADA during minting. Since I have all of my funds on my Ledger now, I want to use that to secure the minting policy as well as to pay for the minting. Fortunately, the documentation of cardano-hw-cli
even contains a page about that: https://github.com/vacuumlabs/cardano-hw-cli/blob/develop/docs/token-minting.md
At least version 1.11.0 of cardano-hw-cli
is needed, since the previous version(s) had a bug making the metadata hash of the transaction invalid: https://github.com/vacuumlabs/cardano-hw-cli/releases
The Policy
According to the cardano-hw-cli
guide and CIP 1855, I will use the key with the derivation path m/1855’/1815’/7’ for this NFT. A .vkey
file with the public key and a .hwsfile
containing configuration to later ask the Ledger to sign our minting transaction can be requested from the hardware wallet by:
$ cardano-hw-cli address key-gen --path 1855H/1815H/7H \
--verification-key-file policy.vkey --hw-signing-file policy.hwsfile
(The hardware wallet will ask permission to export a public key during this command.)
The hash of the public key can then be queried as usual by:
$ cardano-cli address key-hash --payment-verification-key-file policy.vkey
c77c5ee379e2f6b21e63206010467a1abe3e54703aeccb558cf0bb19
I want the policy to lock at the beginning of the next epoch (357 at the time of writing). That is slot (357−208)×432 000+4 492 800=68 860 800.
Altogether, this gives the following policy.script
file:
{
"type": "all",
"scripts": [
{
"type": "before",
"slot": 68860800
},
{
"type": "sig",
"keyHash": "c77c5ee379e2f6b21e63206010467a1abe3e54703aeccb558cf0bb19"
}
]
}
We can now get the policy ID:
$ cardano-cli transaction policyid --script-file policy.script
43c4b46c9635b3a57c9731de817a20b3d099ee2c7f44a0e2723c4bb6
And we put it into the metadata.json
file created above:
{
"721": {
"43c4b46c9635b3a57c9731de817a20b3d099ee2c7f44a0e2723c4bb6": {
"HeptaCard": {
[…]
The NFT, the token, the asset is identified by the combination of policy ID and asset name. For the cardano-cli transaction build
command line, we need the hex encoding of the asset name. We can get that, for example, by:
$ echo -n HeptaCard | basenc --base16
486570746143617264
The Payment Key
Since I want the second account on the Ledger to pay for the mint and receive the NFT, and I am using this account in single-address mode in other wallet apps, the key that I have to export from the Ledger is m/1852’/1815’/1’/0/0. Again, I get a .vkey
and a .hwsfile
:
$ cardano-hw-cli address key-gen --path 1852H/1815H/1H/0/0 \
--verification-key-file payment.vkey --hw-signing-file payment.hwsfile
(Again, the hardware wallet will ask permission to export a public key during this command.)
I already know the address that I want to spend from and that I want to receive the NFT on, but just to show how it works: It is a delegated address, so we need to have the public stake key as well and then can put it into cardano-cli address build
:
$ cardano-hw-cli address key-gen --path 1852H/1815H/1H/2/0 \
--verification-key-file stake.vkey --hw-signing-file stake.hwsfile
$ cardano-cli address build --mainnet \
--payment-verification-key-file payment.vkey --stake-verification-key-file stake.vkey
addr1qyh72hvvrurjvddx4gq37jd2fzyef8scz9cwcyc90dffq0xxllh3nc5r82ujj36fy9zh0gryqvqy7r3ejd2h2kgsvryswhjr9q
Yep, that is the address I already know from my wallet apps.
As usual, we can search for an unspent transaction output (UTxO) to pay for our minting by:
$ cardano-cli query utxo --mainnet \
--address addr1qyh72hvvrurjvddx4gq37jd2fzyef8scz9cwcyc90dffq0xxllh3nc5r82ujj36fy9zh0gryqvqy7r3ejd2h2kgsvryswhjr9q
TxHash TxIx Amount
--------------------------------------------------------------------------------------
[…]
dec49e5f9fb6d8bdbd5f4c9add67ad0a3d3e8f7b1853a5d2ef078dd053620768 2 12363808 lovelace + TxOutDatumNone
[…]
(Searching for an UTxO in a wallet app, e.g., Eternl, might be more comfortable.)
The Mint Transaction
We now have everything we need to build the mint transaction:
$ cardano-cli transaction build --mainnet \
--tx-in dec49e5f9fb6d8bdbd5f4c9add67ad0a3d3e8f7b1853a5d2ef078dd053620768#2 \
--mint "1 43c4b46c9635b3a57c9731de817a20b3d099ee2c7f44a0e2723c4bb6.486570746143617264" \
--mint-script-file policy.script --invalid-hereafter 68860800 --metadata-json-file metadata.json \
--tx-out "addr1qyh72hvvrurjvddx4gq37jd2fzyef8scz9cwcyc90dffq0xxllh3nc5r82ujj36fy9zh0gryqvqy7r3ejd2h2kgsvryswhjr9q+2824075+1 43c4b46c9635b3a57c9731de817a20b3d099ee2c7f44a0e2723c4bb6.486570746143617264" \
--change-address addr1qyh72hvvrurjvddx4gq37jd2fzyef8scz9cwcyc90dffq0xxllh3nc5r82ujj36fy9zh0gryqvqy7r3ejd2h2kgsvryswhjr9q \
--witness-override 2 --cddl-format --out-file transaction.raw
Estimated transaction fee: Lovelace 537345
- The
--tx-in
is the UTxO, we have found at the end of The Payment Key. - The policy ID and hex-encoded asset name in
--mint
were derived at the end of The Policy and we want to mint one token, since it’s supposed to be an NFT. - The
--mint-script-file
was also constructed in The Policy. The slot in--invalid-hereafter
is exactly the one given in the policy. The--metadata-json-file
is the one built in The Metadata and later updated with the policy ID. - The address to send to in
--tx-out
and--change-address
was derived in The Payment Key (but it could be any other of the user’s choice). The 2824075 Lovelace were chosen, because it is also the amount accompanying every other token in my wallet (by Eternl’s choice). We could have given an amount that is surely too small – say 1 ADA, 1000000 Lovelace – andcardano-cli
would have complained giving the minimal amount needed. The asset part of--tx-out
is exactly the one we have already seen in--mint
. - We need a
--witness-override
of 2, because we want to sign with two keys – the policy and the payment key. - Using the new
--cddl-format
is important, so thatcardano-hw-cli
can deal with the transaction.
The raw format has to be transformed by cardano-hw-cli
into a form that it can use:
$ cardano-hw-cli transaction transform --tx-file transaction.raw --out-file transaction.canonical
The transaction contains following fixable errors:
- CBOR is not canonical (transaction)
- Optional empty lists and maps must not be included as part of the transaction body or its elements (transaction_body.collaterals)
- Optional empty lists and maps must not be included as part of the transaction body or its elements (transaction_body.required_signers)
Transformed transaction will be written to the output file.
Now, we can sign the raw transaction with both witnesses – the policy key as well as the payment key:
$ cardano-hw-cli transaction witness --mainnet \
--tx-file transaction.canonical --hw-signing-file policy.hwsfile --out-file policy.witness
$ cardano-hw-cli transaction witness --mainnet \
--tx-file transaction.canonical --hw-signing-file payment.hwsfile --out-file payment.witness
(For both commands, the hardware wallet will ask if it is okay to sign – giving a lot of detailed information about the transaction before that.)
Finally, we just have to assemble the canonical transaction and the witnesses and submit:
$ cardano-cli transaction assemble --tx-body-file transaction.canonical \
--witness-file policy.witness --witness-file payment.witness --out-file transaction.signed
$ cardano-cli transaction submit --mainnet --tx-file transaction.signed
Transaction successfully submitted.
Et Voilà
The minting transaction can now be seen on Cardanoscan: https://cardanoscan.io/transaction/de3271da5fc532947b344f53771391370c2bd0b9a69ba0981b68b1581de6a811
While I just navigated there from my wallet, I could have found the transaction ID by:
$ cardano-cli transaction txid --tx-file transaction.signed
de3271da5fc532947b344f53771391370c2bd0b9a69ba0981b68b1581de6a811
(… and that works even before the transaction is submitted, so it can be used to wait for the transaction showing up on Cardanoscan or in an API.)
And our minted on-chain NFT is visible on pool.pm: https://pool.pm/asset10zsrgd7329r73aq7ms3hvv0k2c727q55uszdfc
I also just navigated there from my wallet. Unfortunately, cardano-cli
and other standard tools do not have an option to derive the asset1…
fingerprint. It is specified in CIP 14 and the reference implementation is here: https://www.npmjs.com/package/@emurgo/cip14-js