← Back to list

An ERC-721 with Fully On-Chain SVG

*Part 3 of the DEXignation Series. Estimated read time: 7 min.*

Roy in DEXignation · 2026-05-27 15:30 · 0 claps · 4.1 min read
#nft #solidity #erc721 #svg #web3
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3

An ERC-721 with Fully On-Chain SVG

Part 3 of the DEXignation Series. Estimated read time: 7 min.

— -

Most NFTs are not on-chain. Their tokenURI returns an IPFS or HTTPS link, and a JSON document at that location points at another link where the image actually lives. Two layers of indirection. Two opportunities for the artwork to vanish.

DEXignation does it differently. Every .dex name’s image, metadata, and JSON are generated inside the contract on every call to tokenURI. As long as the contract exists and the chain runs, the artwork exists.

This post walks through how that works in roughly 80 lines of Solidity, and what trade-offs we accepted along the way.

— -

What “fully on-chain” actually means

There are three things a tokenURI returns:

  1. A URI.
  2. That URI resolves to a JSON document.
  3. The JSON’s image field points at an image.

“Fully on-chain” means all three live in contract storage or are computed from it — no network round-trips. The standard technique is data: URIs.

A data: URI lets you inline content directly into a URL:

data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCI+…
data:application/json;base64,eyJuYW1lIjoiYWxpY2UuZGV4Iiwi…

Both fields above contain Base64-encoded payloads. The browser, the NFT marketplace, or the wallet can decode and display them directly. No server needed.

— -

The DEXignation implementation

Here’s the full tokenURI from contracts/registrar/DXRegistrar.sol:

function tokenURI(uint256 tokenId) public view override returns (string memory) {
 _requireOwned(tokenId);
 string memory label = names[tokenId];
 if (bytes(label).length == 0) {
 label = “?”;
 }
 string memory dotTld = string.concat(“.”, baseNodeName);
 string memory svg = _generateSVG(label, dotTld);
 string memory json = string.concat(
 ‘{“name”:”’, label, dotTld, ‘“,’
 ‘“description”:”DEXignation Name: ‘, label, dotTld, ‘“,’
 ‘“image”:”data:image/svg+xml;base64,’,
 Base64.encode(bytes(svg)),
 ‘“}’
 );
 return string.concat(
 “data:application/json;base64,”,
 Base64.encode(bytes(json))
 );
}

Reading it top-to-bottom:

  1. _requireOwned(tokenId) — OpenZeppelin’s check that the token exists and has an owner. If not, revert.
  2. Look up the original label string from the names[tokenId] mapping. Fall back to ”?” for safety (shouldn’t happen for properly registered names).
  3. Compose ”.dex” from baseNodeName so the same code works for future TLDs.
  4. Generate the SVG inline.
  5. Build the metadata JSON, embedding the SVG as a data:image/svg+xml URI.
  6. Wrap the whole JSON in data:application/json and return.

That’s the entire metadata pipeline.

— -

The SVG generator

function _generateSVG(string memory label, string memory dotTld)
 internal pure returns (string memory)
{
 return string.concat(
 ‘<svg width=”400" height=”400" xmlns=”[http://www.w3.org/2000/svg](http://www.w3.org/2000/svg)">'
 ‘<defs><linearGradient id=”bg” x1=”0" y1=”0" x2=”1" y2=”1">’
 ‘<stop offset=”0%” stop-color=”#07080A”/>’
 ‘<stop offset=”100%” stop-color=”#0D1117"/>’
 ‘</linearGradient></defs>’
 ‘<rect width=”400" height=”400" rx=”20" fill=”url(#bg)”/>’
 ‘<rect x=”8" y=”8" width=”384" height=”384" rx=”16" fill=”none” stroke=”#00DC82" stroke-opacity=”0.2"/>’
 ‘<text x=”200" y=”170" text-anchor=”middle” font-family=”sans-serif” font-weight=”bold” font-size=”40" fill=”#00DC82">’,
 label,
 ‘</text>’
 ‘<text x=”200" y=”220" text-anchor=”middle” font-family=”sans-serif” font-size=”28" fill=”#64748B”>’,
 dotTld,
 ‘</text>’
 ‘<text x=”200" y=”360" text-anchor=”middle” font-family=”monospace” font-size=”11" fill=”#2D3A48">DEXignation Name Service</text>’
 ‘</svg>’
 );
}

The function is pure — no state reads, no external calls. For a given label and TLD, it always produces the same SVG. That makes it a clean target for off-chain caching by marketplaces.

A few choices worth noting:

  • Sans-serif system font. Embedding a font in the SVG would inflate output by 50–100 KB. Falling back to whatever the renderer has is fine for a name display.
  • Solid colours and a gradient, no images. Pure SVG primitives so there’s nothing to load externally.
  • Title and subtitle separation. The label and the .dex are on different lines so long Korean or unicode labels still read cleanly.

— -

The Base64 double-encoding dance

The outermost wrapper is data:application/json;base64,<JSON-base64>. Inside the JSON, the image field is another data:image/svg+xml;base64,<SVG-base64>. We Base64-encode twice.

Why? Because both URIs are syntactically URLs, and a URL can contain characters that have special meaning in URL parsing (#, ?, &, spaces, quotes). Base64 normalises everything to a URL-safe alphabet.

The decoder does this in reverse:

1. Strip the “data:application/json;base64,” prefix.
2. Base64-decode → JSON string.
3. Parse JSON, read `image` field.
4. Strip the “data:image/svg+xml;base64,” prefix.
5. Base64-decode → SVG.
6. Render.

We use OpenZeppelin’s Base64 utility, which is well-tested and gas- efficient. The full overhead is about 33% more bytes per encoding pass, i.e. our 1 KB SVG becomes ~1.4 KB Base64'd, embedded in a ~1.5 KB JSON which becomes ~2 KB Base64'd. Still cheap.

— -

Why we store the original label

ENS doesn’t store the human-readable label. Only its keccak256(label) (the “labelhash”). This saves a storage slot per registration. The downside is you can never reconstruct ”alice” from labelhash — you can only verify a candidate.

DEXignation stores the label:

mapping(uint256 => string) names;

// inside register():
names[id] = label;

The tradeoff is one extra SSTORE per registration. That’s roughly 20,000 gas. At Polygon’s typical gas price (around 30 gwei) and POL price (around $0.4), this costs about $0.0024 per registration. We accept that.

In exchange, we get:

  • Renderable tokenURI. The whole point of this post.
  • Future-proofed on-chain features. Subdomain listings, search, display logic — all of them benefit from having the canonical label stored.

— -

Trade-offs to consider

I’d be lying if I said fully-on-chain is always the right call. Things you give up:

  • Bigger contract bytecode. SVG and Base64 logic add bytes. Stay well under the 24 KB contract limit; we use ~12 KB for the whole registrar.
  • No high-fidelity art. A 4 KB SVG of geometric shapes is fine. A 200 KB photographic NFT is not. Pick the medium accordingly.
  • view-call gas cost. tokenURI does work on every call. Marketplaces cache it, but pay attention if you’re calling it in a loop.
  • No off-chain enhancement. With an off-chain renderer you can update the art design without redeploying. With on-chain SVG, the art is locked.

For a name service the trade-offs are obviously favourable. For an art project where the art is the value, you’d want a different answer.

— -

What you can do next

  • Read the function in contextDXRegistrar.sol.
  • Try generating a tokenURI locally — deploy to Hardhat, register a name, call tokenURI(tokenId), paste the resulting data: URI into your browser. The SVG should render.
  • Customise the SVG_generateSVG is internal but very easy to fork. Match it to your brand if you reuse DEXignation as a base.

— -

Previous: Part 2 — namehash Explained Next: Part 4 — Stopping Front-Running with Commit-Reveal

— -

🇰🇷 Korean version available at docs.dexignation.com/blog.](https://docs.dexignation.com/blog).*)


메타데이터
post_id
a5df87899cf7
slug
an-erc-721-with-fully-on-chain-svg-a5df87899cf7
url
https://medium.com/dexignation/an-erc-721-with-fully-on-chain-svg-a5df87899cf7
canonical_url
https://medium.com/dexignation/an-erc-721-with-fully-on-chain-svg-a5df87899cf7
author_url
https://medium.com/@punditcode
status
ok
fetched_at
2026-06-13 16:00:06