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`:
```solidity
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
```solidity
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">'
‘<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.
Get Roy’s stories in your inbox
Join Medium for free to get updates from this writer.
— -
## 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:
```solidity
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 context** —
[`DXRegistrar.sol`](https://github.com/DEXignation/dexignation-contracts/blob/main/contracts/registrar/DXRegistrar.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](./02-namehash-explained.md)*
*Next: [Part 4 — Stopping Front-Running with Commit-Reveal](./04-commit-reveal.md)*
— -
🇰🇷 *Korean version available at [docs.dexignation.com/blog](https://docs.dexignation.com/blog).*







