Fully On-Chain NFTs
Non-Fungible Tokens (NFTs) are made on blockchains, which means they are on-chain, right? Not so fast: it turns out it is a lot more complicated than that. Even if they are smart contracts, most NFTs rely on things outside of the blockchain. Let’s look at why, and how Relic worked around these shortcomings.
What is an NFT?
At its core, an NFT is simply a smart contract that obeys the ERC-721 token standard. The basic concept here is that there is a smart contract which tracks individual tokens, and with each token associates an owner. Because the tokens are tracked individually, we can refer to specific tokens: it makes sense to say “Alice holds token 123” and “Bob holds token 82”. This is in contrast with fungible assets: if Alice and Bob each have a dollar, nothing interesting happens if their two dollars swap.

NFTs don’t really have anything to do with artwork, except that it is one common example of non-fungible assets. Paintings, for example, are unique — Alice’s Seurat is quite different from Bob’s Picasso. But NFTs could really be anything that has a unique identifier and can be owned: software licenses, complicated financial positions, domain names, and so on.
NFTs and Art
By themselves, NFTs actually have no images associated with them. That comes from an optional extension to the ERC-721 specification to provide metadata. The tokenURI function takes in a token ID, and returns a Uniform Resource Identifier (URI) — essentially a URL, but slightly more generic. That URI points to a JSON string which contains metadata such as the name, description, and finally an image for the individual token.
So this is where the artwork comes in to play. But how are these URIs set up? There’s a dirty secret: a lot of these URIs simply point to Web2 servers. If you want to view your NFT, you (or OpenSea or other apps) will query the Web2 URI for the image data. This obviously has a lot of negative consequences:
- What if the company hosting the data decides to return something else?
- What if the company hosting the data forgets to renew their DNS record, and a malicious entity takes over?
- What if the company hosting the data gets hacked?
- What if the company hosting the data goes out of business?
Put simply, this isn’t decentralized.

IPFS
The Inter-Planetary File System (IPFS) is one way to attempt to address these problems. IPFS is effectively a decentralized file store. Each file gets a cryptographically unique identifier based on the contents of the file. By using the file contents to derive identifiers, the receiver can easily verify if the data matches the identifier it requested!
This removes one big class of issues by ensuring malicious entities can’t replace your data. However, it still has a few problems.
IPFS still requires that someone in the network hosts the data. While it ensures that any data delivered is correct, it doesn’t guarantee data is delivered at all. For popular information that many people would mirror (such as Wikipedia or popular media files) this is not a concern. But for niche files, it’s possible only one server is hosting the files. What will happen to this data in 1 year? in 10? it’s hard to say.
The next issue is that while IPFS is a popular and successful project, most users only access it through centralized gateways. Most users today access IPFS data through a limited number of gateways such as Cloudflare or ipfs.io which are very convenient, but not very decentralized. If these gateways wanted to, they could easily censor or modify data. This is not a fundamental limitation of IPFS, and hopefully more users will directly access the IPFS network in the future rather than relying on centralized gateways.
Another difficulty of using IPFS is that files cannot change. This is fundamental to its security model, but it also presents some limitations. What if the NFT’s data should change over time? This isn’t a problem for static images or videos, but there is no reason NFTs need to be static files. It also means that for NFTs generated on the fly, a component needs to exist to generate and upload the images to IPFS.
IPFS is very cool technology and a great project. But at the end of the day it’s not natively available on Ethereum. For many use cases this is acceptable or even preferable, but for an on-chain object like an NFT, introducing any off-chain dependencies is simply introducing another point of failure.
Blockchain Storage
Luckily there is another option that solves the problems of Web2 storage and IPFS: the blockchain we are already using!
Ethereum already requires data be storable and accessible. Once added to the blockchain, the data will be available permanently (at least as long as the blockchain continues to operate), and secured cryptographically the same way all data in the blockchain is.
There is one downside with this approach: resources on the blockchain are limited, so storing data can be very expensive. This just means we need to be thoughtful with what we store and how we use data to keep costs down.
Design Goals
Our first NFT associated with Relic is the “Birth Certificate”. Although the data Relic generates exists independently of the NFTs, we thought it would be a more accessible way for users to understand their data.

When a user proves their Ethereum “birth date”, Relic will issue them a Soulbound NFT. This will be generated dynamically, based on when their account was created. We want these NFTs to be fully decentralized and trustless, just as the protocol itself is.
With these requirements, we decided our best option was to build dynamic SVG NFTs on-chain.
SVGs
Scalable Vector Graphics (SVG) are a way of specifying how to draw images, rather than a collection of pixels (or raster graphic). Aside from infinite scaling, simple images can be much smaller, because their description may be very succinct. It also means it is easier to manipulate: if you want to change something from a circle to a square, or red to blue, it’s a simple change of the XML text that makes up the SVG.

The above graphic is made by the following SVG code:
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<rect x="30" y="30" width="60" height="60" fill="blue"/>
<circle cx="40" cy="40" r="30" fill="red"/>
</svg>
This clocks in at 174 bytes (and can scale infinitely) compared to the PNG version at 1181 bytes.
This ability to specify and modify images with ease makes them well suited to generating on-chain. But how does this work in practice?
Implementation
The first thing we need to do is create URIs that use on-chain data. Unlike for HTTP or IPFS, there is no URI scheme for locating on-chain data by pointing to an Ethereum contract or anything like that. However, we can use data URIs. A data URI allows us to encode what we want to point to directly in the URI itself.
For example:
data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHJlY3QgeD0iMzAiIHk9IjMwIiB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIGZpbGw9ImJsdWUiLz4KPGNpcmNsZSBjeD0iNDAiIGN5PSI0MCIgcj0iMzAiIGZpbGw9InJlZCIvPgo8L3N2Zz4K
will render as the above SVG image with the circle and square.
Unfortunately, data URIs commonly use Base64 encoding which makes them a bit more annoying to work with. However, we can use OpenZeppelin’s Base64 function to handle all of the encoding natively on-chain.
Thus, the outline for our NFT metadata construction is something like:
- build a skeleton JSON top level metadata object
- build a skeleton SVG that we can inject data into for dynamic content
- insert the required data when the token is minted and Base64 the image
- put the image URI into the JSON metadata, and Base64 the JSON
- return the data from the tokenURI call
Optimizing Images
Right away we hit a few hiccups. First, the design we chose for our NFTs has 12 different animal figures, each with 5 different colors for the different agents of the Asian Zodiac. These are raster graphics that can’t easily be transformed into an SVG.
Let’s not panic: we can simply embed these images as yet another data URI inside our SVG!
Originally each image was about 400kB, and we had 60 of them, totaling about 24MB of image data. On Ethereum, storage costs are roughly 20k gas/32 bytes, which would lead to a cost of 300 ETH at 20Gwei gas prices.
Maybe panicking was appropriate…

Luckily, this data will only be written once and stored forever, so instead of using storage, we can use contract data, which is about 1/3 the cost: about 5M gas/24kB, or about 100ETH at 20Gwei prices.
I still don’t think we’ve got the budget for that, so maybe we can make our images a bit smaller…
Our images were originally PNGs with transparency, which means we can’t easily use JPEG (which doesn’t support transparency). But we can use the relatively new format WebP. Without any fiddling, we can turn a 300kB PNG into a 50kB WebP image using cwebp.
To make deployment and coding easier, it would be much more convenient if we could get the image small enough to fit in a single smart contract though, which means about 24kB. Luckily we have some options: the alpha channel of the image can be compressed at a lower quality than the color channel, and we can adjust all of this until it fits in our size budget.

Now we’re talking! Using a simple script, we can adjust the quality levels of all our images until they can fit in a single contract. We’ve now brought our cost for the images to 60 x 5M gas, about 6ETH at 20Gwei prices. There is a subtle loss in quality which isn’t great, but it is acceptable for our use case.
Luckily, there is one more optimization we can do. The different colors of animals are all very similar assets. Rather than storing each image 5 times, can we use code in our SVG to modify the color?
It turns out we can do this with SVG filters! Much like adjusting the levels, curve, hue, and saturation in an imaging program, we can do that directly from code. This gives us an additional 5x reduction in costs to about 1.2 ETH at 20Gwei. Still pricey, but especially if we can wait for cheaper gas, that is quite reasonable.
The contract for each of these images is very simple:
contract Image {
function image() external pure returns (bytes memory) {
return 'HEX ESCAPED IMAGE DATA GOES HERE';
}
}
Our main URI creator contract will receive the list of the image contracts in its constructor and call into them as needed to fetch the different animal images to construct the SVG.
Optimizing Text
The next issue is how to handle our text. Our designer worked hard to pick the right font, so we need to be sure to use that. However, we don’t want to link to the font on the web, because that would be centralized! Luckily we can use our favorite thing in the world: more data URIs!
Of course, our fonts are once again too large: about 150kB for each of the three separate fonts we use. Luckily there is a trick that web developers use for optimizing font sizes: because we know the set of characters we might use for each font (for example, the letter x and then 0–9 and a-f for the monospaced font), we can remove all the font information about other characters. With this, we are left with about 6kB of font data. We could probably reduce it further, but that’s within acceptable limits.
Similar to the images, we stick the fonts in a separate contract for storage — since they are small we can use one contract for all three fonts we use.
Bonus
One fun thing about SVGs is they support animations natively. This means we can easily add in animations like slowly moving the astrological signs in the doorway and scrolling the address text around the edge.
In something like a GIF this would require separate rendering for each frame and take up lots of space. But in SVG all we have to do is write a quick line like
<animate attributeName="x" from="0" to="21" dur="5s" repeatCount="indefinite"/>
and suddenly we can move a background.
We limited our animations to keep our graphics simple, but there are lots of really cool opportunities here for other projects!
The main caveat here is that some NFT viewers may not support newer SVG functionality like some filters and animations. The good news is popular browsers (Chrome, Safari, Edge, Firefox, Brave, Opera) will be fine, and any viewer that doesn’t support it will likely get upgraded in the future to support the rest of the features.
Bringing it Together
At this point we’re basically done! We’ve deployed 12 contracts of images, 1 contract of font data, and 1 contract for the SVG itself. In our case, this contract is separate from the actual ERC-721 contract for the Birth Certificate.
Our main URI creator contract’s SVG skeleton (and main JSON metadata skeleton) is also populated with a few pieces of information that may change depending on information on chain. For us that means:
- color filter applied to Zodiac animal
- background pattern for astrological sign
- direction for astrological sign animation
- text for birth date & time
- text for block number
- text for address
- data URIs to be filled in by external calls to image and font contracts
In practice this is as simple as splitting up the SVG into chunks of static content (such as the background imagery, and some of the text), and joining it together with the dynamic elements with string.concat
with something like:
function image(uint256 tokenId) internal view returns (string memory) {
return
Base64.encode(bytes(string.concat(
CHUNK1,
dynamic_element1(tokenId),
CHUNK2,
dynamic_element2(tokenId),
...
CHUNK_FINAL
)));
}
In our case the dynamic elements are based on the token ID. However, this need not be the case: it’s possible to create a token where the dynamic elements change in response to other state or actions.
For example, one could make the animals in our NFT images grow larger over time by calculating the size dynamically every time tokenURI
is called. There are lots of interesting possibilities for other NFT techniques using this!

This diagram shows the relationships between the main contracts for our NFT and should help make things a bit easier to understand. The diagram omits the internal calls for populating dynamic content in the skeletons, but contains the external calls between contracts.
Although the SVG data generated is a bit ugly, it is possible to inspect and modify the SVG data from the Relic NFTs (or any SVG NFT) by simply viewing the NFT on OpenSea or similar and using the browser view source function, or downloading it and using a text editor.
One reasonable question is: how much gas does it take to call the tokenURI
function? It’s a lot. Luckily the function is a view function and can be called off-chain for “free”. So while it may not be practical to get the URI data from a smart contract, it is easy for NFT viewers like OpenSea to do so.
Takeaways
There are a few important things we’ve learned here. First, it makes sense to keep NFTs purely on-chain: it’s the only way to ensure they are as permanent and accessible as the blockchain is. Luckily, with only a bit of work, it’s possible to build NFTs in this way. This also enables richer dynamic content where the events on the blockchain can cause changes in the images.
Although purely vectorized SVGs are the ones most commonly used on-chain, we aren’t limited by this! We were able to combine raster images and vector images for a richer design. Although this cost a bit more in gas, we were able to get costs down to pretty reasonable levels.
Relic
How does this connect to the Relic Protocol ecosystem? Our soulbound tokens are not used themselves by the protocol. Because they are only issued once a user’s account is cryptographically proven to have existed in a block, they serve as an alternate representation for the facts stored by Relic. And because they are soulbound (not transferable), owning one is equivalent to the fact being stored by Relic. However, we expect applications that wish to verify account age to query the Reliquary contract rather than attempt to verify facts using NFTs.
Of course, if you would like to make one of these soulbound NFTs for yourself, you can do so at https://app.relicprotocol.com !
If you want to know more about Relic, check out the first post in our series: Building Relic
If you have any questions, feel free to let us know on Twitter or Discord