An ERC-721 with Fully On-Chain SVG
*Part 3 of the DEXignation Series. Estimated read time: 7 min.*
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:
- A URI.
- That URI resolves to a JSON document.
- The JSON’s
imagefield 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:
_requireOwned(tokenId)— OpenZeppelin’s check that the token exists and has an owner. If not, revert.- Look up the original label string from the
names[tokenId]mapping. Fall back to”?”for safety (shouldn’t happen for properly registered names). - Compose
”.dex”frombaseNodeNameso the same code works for future TLDs. - Generate the SVG inline.
- Build the metadata JSON, embedding the SVG as a
data:image/svg+xmlURI. - Wrap the whole JSON in
data:application/jsonand 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
.dexare 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.tokenURIdoes 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 context —
DXRegistrar.sol. - Try generating a
tokenURIlocally — deploy to Hardhat, register a name, calltokenURI(tokenId), paste the resultingdata:URI into your browser. The SVG should render. - Customise the SVG —
_generateSVGis 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