Skip to main content

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:

  1. Per-tier SVG templates — one template per rarity tier, as immutable bytes
  2. Trait sprite library — a small lookup table indexed by trait ID, each entry is a tiny byte string
  3. Per-token traits — packed into 4 bytes inside the existing Fantums struct
// 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:

TemplateSize estimateContents
Common~1.5 KBPumpkin Gold base body, OG-style outline, slot placeholders
Uncommon~1.8 KBPorcelain body, Fluro Blue accent slots, slot placeholders
Rare~1.8 KBPorcelain body, Fluro Green accent slots, slot placeholders
Epic~1.8 KBPorcelain body, Fluro Pink accent slots, slot placeholders
Legendary~3.5 KBTwo 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).

CategoryTrait countAverage size
Hats12~40 bytes each
Capes8~50 bytes each
Held items12~45 bytes each
Face mods8~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

PathCost
Mint hot pathUnchanged. No extra writes from the renderer.
tokenURI read~30k extra gas per call vs a static URI
Cost-bearerCaller 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:

  1. Reading Fantums.tokenURI(tokenId) from the contract directly via RPC
  2. Decoding the base64 payload
  3. Inspecting the inlined SVG
  4. 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


Last updated: 2026-05-21