DNA Image Layers - ERC721TopDownDna
In this tutorial we will create an NFT with a detachable hat. See final code on GitHub.
Tutorial
The ERC721TopDownDna.sol smart contract combines two of our primary features:
- TopDown (ERC721TopDownBase.sol)
- Allows an NFT to own other NFTs on-chain
- Exposes all owned NFT data to their owner
- DNA (ERC721DnaBase.sol)
- Allows a standard encoding of NFTs on-chain using an off-chain schema
Use Case: PFPs with Detachable Accessories or Equipment

NFT with Hat

NFT with Hat Removed
In this tutorial, we will create a Dynamic PFP (Profile Picture NFT).
- The hat is its own NFT: it can be detached and re-attached to the main NFT.
- When you attach the hat, the PFP will show the hat.
- When you remove the hat, it can be tradeable with other hats or sold.
- The on-chain data, and JSON Schema is all that is required to render the NFT graphics.
Ensure you're able to build the entire project before starting: see Getting Started.
Step 1: Prepare the layers
The sample will have a small number of layers for each trait
:
These must be transparent PNGs. These will be combined, so the positioning must be taken into account.
Background

Dunes

Downtown
Note: no background at all is also an option.
Body

Common

Albino
Hats (the detachable accessory)

Regular Cap

Beanie

Beret

Cowboy Hat
We'll also encode into the DNA/on-chain data of the NFT an enum
Vibe, which has 3 values:
- Chill
- Boring
- Eccentric
Step 2: Setup the project folder
You will need to clone our starter-cli repository, install the dependencies and create a folder for your project, say my-example-omo
.
git@github.com:owlprotocol/starter-cli.git
cd starter-cli
# Note: npm is sufficent, but we recommend using pnpm
pnpm install
mkdir projects/my-example-omo
cd projects/my-example-omo
This is recommended so that you can have a working package.json
, and tsconfig
.
Step 3: Declare the traits in JavaScript
For the traits
in this example we will use the nft-sdk
to instantiate an NFTGenerativeCollectionClass and use the CLI
tool to generate the JSON Schema, which we upload to IPFS.
The
nft-sdk
needs this JSON Schema to translate the on-chain data (DNA).
We start by defining the traits as NFTGenerativeTraits.
, then add them to the NFTGenerativeCollection
.
traits.ts
import { NFTGenerativeTraitEnum, NFTGenerativeTraitImage } from '@owlprotocol/nft-sdk';
export const traitEnumVibe: NFTGenerativeTraitEnum = {
name: 'Vibe',
type: 'enum',
options: ['Chill', 'Boring', 'Eccentric'],
probabilities: [70, 20, 10],
};
export const traitImageBg: NFTGenerativeTraitImage = {
name: 'Background',
type: 'image',
image_type: 'png',
options: [
{
value: 'None',
image_url: 'ipfs://QmeYhQsx2PGeKoCco8Ck4gUcoSNN7ecShKcZaXDsHardQL',
},
{
value: 'Dunes',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/bg-dunes.png',
},
{
value: 'Downtown',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/bg-downtown.png',
},
],
probabilities: [1, 5, 5],
};
[...]
See traits.ts
on GitHub
probabilities
are normalized, correspond in order with the values, and there must be as many probabilities as values.
You need to manually upload images to IPFS, and add the image_url
as ipfs://[hash]/[path]
.
The ipfs://
will be replaced by the environemnt variable IPFS_GATEWAY
that used by our provided API, so you don't need to be concerned about that.
We will have more tools and a UI for uploading to IPFS soon.
We will then define a collection that uses these traits.
Step 4: Create the collection.ts
that connects the traits and collection:
collection.ts
import {
traitEnumVibe,
traitImageBg,
traitImageBody,
traitImageHats
} from './traits.js';
import {
NFTGenerativeCollection,
NFTGenerativeCollectionClass,
NFTGenerativeTraitEnumClass,
NFTGenerativeTraitImageClass,
} from '@owlprotocol/nft-sdk';
const collHatsChildDef: NFTGenerativeCollection = {
name: 'Tutorial Example - NFT Hats Sub-Collection',
description: 'Example from https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers',
external_url: 'https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers',
seller_fee_basis_points: 5000,
fee_recipient: '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf',
generatedImageType: 'png',
traits: {
Hats: traitImageHats,
},
};
const collNestedDef: NFTGenerativeCollection = {
name: 'Thread Haus - Innovot NFT Collection',
description: 'Example from https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers',
external_url: 'https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers',
seller_fee_basis_points: 10000,
fee_recipient: '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf',
generatedImageType: 'png',
traits: {
Vibe: traitEnumVibe,
Background: traitImageBg,
Body: traitImageBody,
},
//@ts-ignore
children: {
Hats: collHatsChildDef,
},
};
export const collHatsChild = NFTGenerativeCollectionClass.fromData(collHatsChildDef) as NFTGenerativeCollectionClass<{
Hat: NFTGenerativeTraitImageClass;
}>;
export const collExample = NFTGenerativeCollectionClass.fromData(collNestedDef) as NFTGenerativeCollectionClass<
{
Vibe: NFTGenerativeTraitEnumClass;
Background: NFTGenerativeTraitImageClass;
Body: NFTGenerativeTraitImageClass;
},
{
Hat: NFTGenerativeCollectionClass<{
Hats: NFTGenerativeTraitImageClass;
}>;
}
>;
export default collExample;
See collection.ts
on GitHub
The trait key must be the same as the trait name and is case sensitive.
For example, traitImageBg
has the trait key Background
capitalized. Therefore collExample
must also declares Background
, capitalized.
Step 5: Generate the JSON Schema using the CLI Tool
The JSON Schema is used to interpret and translate the on-chain DNA to data that can be rendered or executed.
We believe storing the on-chain data in a single binary encoded format is ideal because it minimizes the number of esoteric methods on the smart contract. Rather we leave it up to the client to interpret and parse the schema.
This is not to be confused with the Metadata JSON, which is what NFT Marketplaces use to describe the NFT.
Using the CLI to Generate the JSON Schema
- Install the CLI package in the owlprotocol repository.
pnpm install -g @owlproject/nft-sdk-cli
- Compile the
collections.ts
file
In the starter-cli
folder, you can run:
pnpm run build
Under the hood, this command runs:
tsc --project tsconfig.projects.json
This is needed as the CLI take a JavaScript file.
- Now call the
generateJsonSchema
command on the CLI Tool.
owl-cli generateJsonSchema collections.js --projectFolder=projects/my-example-omo
Which should output:
getProjectSubfolder ~/starter-cli/projects/my-example-omo/output
Creating JSON(s) for collections.js to folder: ~/start-cli/projects/my-example-omo/output
projects/my-example-omo collections.js
Done
Ignore any warnings for
duplicate definition
.
Now you should see a new folder in projects/my-example-omo
called output
, and with two JSON files:
- collection-parent.json
- collection-child-Hats.json
Step 6: Upload the JSON Schemas to IPFS
We use Pinata for this tutorial, but you can upload the schema to any IPFS provider including your own.
We have 2 collections here: the main NFT collection and the hat collection. When we mint the NFT from the parent collection, the hat NFT also automatically gets minted, and it gets attached to the newly-minted parent NFT.
For this tutorial you can see the uploaded schemas there:
Keep the IPFS hashes handy. In this example, they are:
- Parent hash:
QmRNrcuGtaqefB72NHuGdDtvzEZjNvX6m2E1AgBXW65EKq
- Hats hash:
QmcYC3fcqxU2gqS7VWEeC7jLDjpFQunMXmfkijXq325RHf
Step 7: Declare collection information in the metadata file
Create a file called owlproject.json
in the project folder. This will contain metadata about the collection.
owlproject.json
{
"rootContract": {
"tokenSymbol": "ExampleOmoNFT",
"tokenIdStart": 1,
"cfg": {
"jsonSchemaEndpoint": "https://leovigna.mypinata.cloud/ipfs",
"sdkApiEndpoint": "https://metadata.owlprotocol.xyz",
"apiPath": "metadata/getMetadata",
"jsonSchemaIpfs": "QmRNrcuGtaqefB72NHuGdDtvzEZjNvX6m2E1AgBXW65EKq"
}
},
"children": {
"Hats": {
"tokenIdStart": 1,
"cfg": {
"jsonSchemaIpfs": "QmcYC3fcqxU2gqS7VWEeC7jLDjpFQunMXmfkijXq325RHf"
}
}
}
}
You should not rely on our API and IPFS endpoints as they are centralized.
Ideally, sdkApiEndpoint
should point to your own web app. For this tutorial, leave it as is.
Important
- You need a working IPFS endpoint. We recommend using Pinata
- Do not change
sdkApiEndpoint
, this is the fallback API for browsers/clients that do not support thenft-sdk
- Replace the
schemaJsonIpfs
for the parent and children according to the JSON Schema from earlier, this is misnamed at the moment.
Step 8: Deploy and mint NFTs
For initial testing, prefer a local blockchain over a testnet. A local blockchain like Ganache is simpler and faster.
- Make sure you have a
.env.development
file. It should contain two values:NETWORK
, andHD_WALLET_MNEMONIC
.
.env.development
NETWORK=ganache
HD_WALLET_MNEMONIC=test test test test test test test test test test test junk
- Start a local Ganache blockchain (see Ganache quickstart. Use the
--wallet.mnemonic
flag to force the same mnemonic as in your.env.development
file:
ganache --wallet.mnemonic "test test test test test test test test test test test junk"
Do not use this mnemonic for production!
- Double check to ensure that the
accounts
in the CLI (networks.json
) match the first account shown byganache
, and thatNETWORK
is set toganache
.
We also support using a single private key.
To use a private key, do not set HD_WALLET_MNEMONIC
and instead declare the environment variable PRIVATE_KEY_0
in .env.development
.
Deploy Common
If you are deploying to a new chain, or a fresh ganache blockchain, the common beacon proxies and implementations need to be deployed first.
We enable this by passing --deployCommon=true
into the deployment command. Don't worry if you forget to remove this flag later. Our deployer always deploys the beacons to the same addresses. Therefore, the deployer will skip deploying beacons if they already exist.
Deploy contracts and mint NFTs
We will define an NFT instance (or item):
Add the following file to the items
folder in your project folder:
collection-item-1.ts
import { collExampleLoyalty } from '../collections.js';
const nftItem = collExampleLoyalty.create({
attributes: {
'Member ID': 1009855,
'Status Tier': 'Silver',
Background: 'Dark',
'Tier Badge': 'Silver',
Points: 123600,
'Sub Group': 'Yacht Club',
},
});
export default nftItem;
Now, run the build process to compile the file into JavaScript:
pnpm run build
We then need to generate the NFT's JSON schema. From the start-cli
folder, run:
owl-cli generateItemNFT items/collection-item-1.js --projectFolder projects/my-example-loyalty
This will output the NFT's JSON schema to projects/my-example-loyalty/output/items/
.
Check it out for yourself!
If everything is set up properly, you can now run:
owl-cli deployTopDown --projectFolder=projects/my-example-omo --deployCommon=true --debug=true
This will deploy and mint all NFT JSONs in the project's /output/items
folder.
At this point make sure you have the following:
- A JSON Schema uploaded to IPFS, and the corresponding IPFS hash in the
owlproject.json
file. - The network configured properly in
.env.development
file andnetworks.json
. - JSON files of the NFTs you will mint in
output/items
.
If the command succeeds you should see an output similar to:
Minted ~/start-cli/projects/my-example-omo/output/items/collection-item-1.json
Mint: Hats at 0x91a4Df19DE444cDA86ef24f61A6190838Cec2b22 - tokenId: 1 & dna: 0x00
Mint: root at 0xe3f62b8f72E49e75081B991685AeA19dd783b44a - tokenId: 1 & dna: 0x000101
Also the NFT item JSON files will be updated to track the deployment:
{
"fullDna": "0x00000000000000...",
"children": {
"Hats": {
"fullDna": "0x000000000000..."
}
},
"deployments": {
"ganache": {
"root": {
"contractAddress": "0xe3f62b8f72E49e75081B991685AeA19dd783b44a",
"tokenId": 1
},
"children": {
"Hats": {
"key": "Hats",
"contractAddress": "0x91a4Df19DE444cDA86ef24f61A6190838Cec2b22",
"tokenId": 1,
"dna": "0x00"
}
}
}
}
}
Step 9: View and check the NFTs
You can use the viewTopDown
command on the CLI to quickly view the NFT:
owl-cli viewTopDown --root=0xe3f62b8f72E49e75081B991685AeA19dd783b44a --tokenId=1
The output should be similar to this:
View ERC721TopDownDna 0xe3f62b8f72E49e75081B991685AeA19dd783b44a on ganache
Fetching Metadata JSON Schema from: https:/leovigna.mypinata.cloud/ipfs/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9
And the following object:
{
Body: {
value: 'Downtown',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/body-albino.png'
},
Background: {
value: 'Downtown',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/bg-downtown.png'
},
Vibe: 'Chill'
}
Hats {
Hats: {
value: 'Beanie',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/hats-beanie.png'
}
}
View the rendered NFT PFP
Typically your app should use the nft-sdk
, instantiate the collection class from the JSON Schema, and read the NFT's DNA to render the NFT.
To do this, we call the viewTopDown
command again, but with the --debug
option.
This will call the NFT contract's tokenURI
method, which is that a NFT Marketplace that does not support the nft-sdk
would typically call.
owl-cli viewTopDown --root=0xe3f62b8f72E49e75081B991685AeA19dd783b44a --tokenId=1 --debug
The output at the end shows:
tokenUri http://metadata.owlprotocol.xyz:32001/metadata/getMetadata/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
fullDna 0x0000000000000...
This tokenUri
is never seen by users, so its complexity is not an issue.
curl
the tokenURI
URL:
curl -s https://metadata.owlprotocol.xyz/metadata/getMetadata/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
You should get the following JSON:
{
"description": "Example from https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers",
"external_url": "https://docs.owlprotocol.xyz/contracts/tutorials/nft-image-layers",
"image": "http://metadata.owlprotocol.xyz:32001/metadata/getImage/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"name": "Thread Haus - Innovot NFT Collection",
"attributes": [
{
"trait_type": "Body",
"value": "Downtown"
},
{
"trait_type": "Background",
"value": "Downtown"
},
{
"trait_type": "Hats (detachable)",
"value": "Beanie"
}
]
}
This is the metadata that an NFT marketplace is looking for.
And you can also see the image
field is a link to the actual image:
http://metadata.owlprotocol.xyz:32001/metadata/getImage/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9/AAAAAA...
Which is this image:
Step 10: Detach the hat
We use the detachTopDown
command to remove/detach the NFT:
owl-cli detachTopDown --root=0xe3f62b8f72E49e75081B991685AeA19dd783b44a -c 0x91a4Df19DE444cDA86ef24f61A6190838Cec2b22 --tokenId=1
Outputs:
Detaching from ERC721TopDownDna on ganache
Fetching Metadata JSON Schema from: https:/leovigna.mypinata.cloud/ipfs/Qmc7Aih1P67dmHF4PDMg5KfLABMtR6DXmDaxRvgF8Wgoe9
{
Body: {
value: 'Downtown',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/body-albino.png'
},
Background: {
value: 'Downtown',
image_url: 'ipfs://QmfSABDaq7V2WKrdTnK3ofnnbucax4e5jBcztXqL34zsrL/bg-downtown.png'
},
Vibe: 'Chill'
}
Now let's view the NFT again:
owl-cli viewTopDown --root=0xe3f62b8f72E49e75081B991685AeA19dd783b44a --tokenId=1 --debug
This gives us different the tokenUri
. Notice that the image is simply accessible via the /getImage
path instead of /getMetadata
.
Gives us:
More Info
Have questions? Join us in Discord: https://discord.com/invite/7sANzfGUfe