On-chain rendering
The deep dive on Pattern A — how Fantums Reborn renders SVG art entirely from on-chain bytes without IPFS, without a metadata API, without an indexer.
The headline of this design is in On-chain art for collectors. This page is the implementation detail for engineers and auditors.
The lesson we're applying
The original Fantums collection stored a base URI of https://api.fantums.com/token/. The server died. The art is gone. Of 10,860 PNGs, 7 were ever cached publicly. That is the failure mode we are not repeating.
Pattern A architecture
Three things stored on-chain:
- Per-tier SVG templates — one template per rarity tier, as immutable bytes
- Trait sprite library — a small lookup table indexed by trait ID, each entry is a tiny byte string
- Per-token traits — packed into 4 bytes inside the existing
Fantumsstruct
// Pseudocode
function tokenURI(uint256 tokenId) external view returns (string memory) {
Fantum memory f = _fantums[tokenId];
bytes memory svg = _assembleSvg(
TEMPLATES[f.tier], // base template
TRAITS[f.hatId], // hat sprite
TRAITS[f.capeId], // cape sprite
TRAITS[f.itemId], // held item sprite
TRAITS[f.faceId], // face mod sprite
f.fluroVariant, // colour swap
f.isOG // OG badge overlay
);
return string(abi.encodePacked(
"data:application/json;base64,",
Base64.encode(abi.encodePacked(
'{"name":"Fantum #',
tokenId.toString(),
'","image":"data:image/svg+xml;base64,',
Base64.encode(svg),
'","attributes":', _attributesJson(f), '}'
))
));
}
The output is a data URI with the full SVG inlined. Marketplaces consuming tokenURI receive everything they need to render in a single call.
What lives in each template
Each rarity tier has one SVG template baked in as immutable bytes:
| Template | Size estimate | Contents |
|---|---|---|
| Common | ~1.5 KB | Pumpkin Gold base body, OG-style outline, slot placeholders |
| Uncommon | ~1.8 KB | Porcelain body, Fluro Blue accent slots, slot placeholders |
| Rare | ~1.8 KB | Porcelain body, Fluro Green accent slots, slot placeholders |
| Epic | ~1.8 KB | Porcelain body, Fluro Pink accent slots, slot placeholders |
| Legendary | ~3.5 KB | Two variants (Void Phantom + Holo Iridescent) selectable by 1-bit flag |
Templates use substitution slots — placeholders like {TRAIT_HAT}, {TRAIT_CAPE}, {TRAIT_ITEM}, {TRAIT_FACE} — that the renderer fills in at read time.
Total renderer + template + trait library size: under 20 KB of contract bytecode. Well inside the EIP-170 24 KB contract size limit.
What's in the trait library
The trait library is a lookup function indexed by ID. Each entry is a small byte string (a few dozen bytes).
| Category | Trait count | Average size |
|---|---|---|
| Hats | 12 | ~40 bytes each |
| Capes | 8 | ~50 bytes each |
| Held items | 12 | ~45 bytes each |
| Face mods | 8 | ~25 bytes each |
Plus a few global sprites:
- OG badge sprite (6x6 pixel sprite as SVG path data)
- Tournament title badge (if a Fantum has won a tournament)
- Trophy DNA counter (if the proposed trophy mechanic is on)
The trait library is a separate contract (FantumTraitLibrary.sol) so it can be replaced if we ship more traits in a v1.1 expansion. The renderer reads from it via an interface.
Per-token packed traits
The trait IDs pack into 4 bytes of the Fantums struct:
struct FantumTraits {
uint8 hatId; // 1 byte (12 hats fit in 1 byte easily)
uint8 capeId; // 1 byte
uint8 itemId; // 1 byte
uint8 faceId; // 1 byte
}
This sits next to the existing combat-stat block in the Fantums struct, so reading both for a tokenURI call costs a single SLOAD.
Gas footprint
| Path | Cost |
|---|---|
| Mint hot path | Unchanged. No extra writes from the renderer. |
tokenURI read | ~30k extra gas per call vs a static URI |
| Cost-bearer | Caller of tokenURI — marketplaces, indexers — not the mint user |
The trade-off is per-call cost paid by readers vs per-mint cost paid by minters. Readers can amortise. Minters cannot.
Base64 encoding
The renderer base64-encodes the SVG inline. We use the gas-optimised Base64 library from OpenZeppelin 5.x (Base64.encode(bytes memory data)). The encoding step accounts for most of the 30k read-time gas overhead — it's not free, but it's known-quantity.
What we considered and rejected
Pattern B — full pixel grid per token. Store the entire 32x32 grid per token. ~1 KB per token × 10,860 = ~11 MB of contract storage. At Sonic gas this is $50k-$100k one-time. Too expensive for marginal gain. Rejected.
Hybrid: chassis on-chain + traits via centralised cache. Stores the body shape on-chain but reads trait assets from a server. Cheap, but reintroduces the exact failure mode that killed the original. Rejected — we are not building a half-on-chain renderer.
Per-tier SVG via IPFS pin. Pin the templates to IPFS, read CIDs from the contract. Cheaper than on-chain bytes but relies on someone keeping the pin alive. Eventually-not-pinned is the IPFS default state. Rejected.
Pattern A wins on a real cost/durability tradeoff: pay slightly more gas, never lose the art.
Verifying the render
Anyone can independently verify a Fantum's render by:
- Reading
Fantums.tokenURI(tokenId)from the contract directly via RPC - Decoding the base64 payload
- Inspecting the inlined SVG
- Re-running the assembly against the same templates + traits
If the on-chain bytes match what's published in the repo, the render is canonical. No central service is involved.
See also
- On-chain art — collector-facing explanation
- Security — the on-chain renderer's audit posture
- Open source — where to read the source
- Art direction — the palette the templates use
Last updated: 2026-05-21