onchain-svg-nft
Decode on-chain SVG NFTs, render base64 tokenURI metadata, and interact with dynamic NFTs that react to Chainlink price feeds.
Dependencies
"use client";
import { useMemo, useState } from "react";
import {
usePublicClient,
useAccount,
useReadContract,
useWriteContract,
useWaitForTransactionReceipt,
} from "wagmi";
import { formatUnits } from "viem";
// ★ ERC-721 tokenURI — reads the on-chain metadata pointer
const erc721TokenURIAbi = [
{
name: "tokenURI",
type: "function",
stateMutability: "view",
inputs: [{ name: "tokenId", type: "uint256" }],
outputs: [{ name: "", type: "string" }],
},
] as const;
// ★ Chainlink AggregatorV3 — only need latestRoundData for price reads
const aggregatorV3Abi = [
{
name: "latestRoundData",
type: "function",
stateMutability: "view",
inputs: [],
outputs: [
{ name: "roundId", type: "uint80" },
{ name: "answer", type: "int256" },
{ name: "startedAt", type: "uint256" },
{ name: "updatedAt", type: "uint256" },
{ name: "answeredInRound", type: "uint80" },
],
},
] as const;
// ★ Minimal mint ABI for dynamic NFT contracts
const dynamicNftMintAbi = [
{
name: "safeMint",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "tokenId", type: "uint256" },
],
outputs: [],
},
] as const;
type NFTMetadata = {
name?: string;
description?: string;
image?: string;
animation_url?: string;
attributes?: Array<{ trait_type: string; value: string | number }>;
};
// ★ Decode base64-encoded on-chain JSON metadata from a data URI
function decodeOnchainMetadata(uri: string): NFTMetadata | null {
if (!uri.startsWith("data:application/json;base64,")) return null;
try {
const json = atob(uri.split(",")[1]);
return JSON.parse(json);
} catch {
return null;
}
}
// ★ Extract raw SVG markup from a base64 or UTF-8 data URI
function extractSvg(metadata: NFTMetadata): string | null {
const imageUri = metadata.image || metadata.animation_url;
if (!imageUri) return null;
if (imageUri.startsWith("data:image/svg+xml;base64,")) {
try {
return atob(imageUri.split(",")[1]);
} catch {
return null;
}
}
if (imageUri.startsWith("data:image/svg+xml;utf8,") || imageUri.startsWith("data:image/svg+xml,")) {
return decodeURIComponent(imageUri.split(",")[1]);
}
return null;
}
// ★ Chainlink ETH/USD on mainnet — works without wallet connection
const DEFAULT_PRICE_FEED = "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419";
const PRICE_THRESHOLD = 2000_00000000n; // $2,000 with 8 decimals
export function OnchainSvgNft() {
const client = usePublicClient();
const { address } = useAccount();
// --- Read & display state ---
const [contractAddress, setContractAddress] = useState("");
const [tokenId, setTokenId] = useState("");
const [metadata, setMetadata] = useState<NFTMetadata | null>(null);
const [rawUri, setRawUri] = useState<string | null>(null);
// ★ Derive SVG content from metadata instead of storing as separate state
const svgContent = useMemo(() => (metadata ? extractSvg(metadata) : null), [metadata]);
const [isFetching, setIsFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
// --- Chainlink price feed state ---
const [priceFeedAddress, setPriceFeedAddress] = useState(DEFAULT_PRICE_FEED);
// ★ Live ETH/USD price — refetches every 30 seconds
const { data: roundData } = useReadContract({
address: priceFeedAddress as `0x${string}`,
abi: aggregatorV3Abi,
functionName: "latestRoundData",
query: { enabled: !!priceFeedAddress, refetchInterval: 30_000 },
});
const ethPrice = roundData ? formatUnits(roundData[1], 8) : null;
const mood = roundData && roundData[1] > PRICE_THRESHOLD ? "bullish" : "bearish";
// --- Mint state ---
const [mintContract, setMintContract] = useState("");
const [mintTokenId, setMintTokenId] = useState("");
const { writeContract, data: txHash, isPending, error: mintError } = useWriteContract();
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash });
// ★ Fetch tokenURI, decode base64 JSON, extract embedded SVG
const handleFetch = async () => {
if (!client || !contractAddress || !tokenId) return;
setIsFetching(true);
setError(null);
setMetadata(null);
setRawUri(null);
try {
const uri = await client.readContract({
address: contractAddress as `0x${string}`,
abi: erc721TokenURIAbi,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
setRawUri(uri);
const decoded = decodeOnchainMetadata(uri);
if (decoded) {
setMetadata(decoded);
return;
}
const res = await fetch(uri);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
setMetadata(await res.json());
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsFetching(false);
}
};
const handleMint = () => {
if (!mintContract || !mintTokenId || !address) return;
try {
writeContract({
address: mintContract as `0x${string}`,
abi: dynamicNftMintAbi,
functionName: "safeMint",
args: [address, BigInt(mintTokenId)],
});
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div>
<h2>On-chain SVG NFT</h2>
{/* --- Section 1: Read & render on-chain SVG --- */}
<fieldset style={{ marginBottom: "1.5rem" }}>
<legend>Read On-chain SVG</legend>
<input
value={contractAddress}
onChange={(e) => setContractAddress(e.target.value)}
placeholder="NFT contract address (0x...)"
style={{ display: "block", marginBottom: "0.5rem", width: "100%" }}
/>
<input
value={tokenId}
onChange={(e) => setTokenId(e.target.value)}
placeholder="Token ID"
style={{ display: "block", marginBottom: "0.5rem", width: "100%" }}
/>
<button onClick={handleFetch} disabled={isFetching || !contractAddress || !tokenId}>
{isFetching ? "Fetching..." : "Fetch SVG NFT"}
</button>
{rawUri && (
<p style={{ wordBreak: "break-all", fontSize: "0.85rem" }}>
URI: <code>{rawUri.length > 80 ? `${rawUri.slice(0, 80)}...` : rawUri}</code>
</p>
)}
{/* ★ Render on-chain SVG in a sandboxed iframe to prevent XSS */}
{svgContent && (
<iframe
sandbox=""
srcDoc={svgContent}
style={{ width: "300px", height: "300px", border: "1px solid #ccc", marginTop: "1rem" }}
title="On-chain SVG NFT"
/>
)}
{metadata && (
<div style={{ marginTop: "1rem" }}>
{metadata.name && <h3>{metadata.name}</h3>}
{metadata.description && <p>{metadata.description}</p>}
{metadata.attributes && (
<ul>
{metadata.attributes.map((attr, i) => (
<li key={i}>
{attr.trait_type}: {attr.value}
</li>
))}
</ul>
)}
</div>
)}
</fieldset>
{/* --- Section 2: Chainlink price feed --- */}
<fieldset style={{ marginBottom: "1.5rem" }}>
<legend>Chainlink ETH/USD Price</legend>
<input
value={priceFeedAddress}
onChange={(e) => setPriceFeedAddress(e.target.value)}
placeholder="Price feed address"
style={{ display: "block", marginBottom: "0.5rem", width: "100%" }}
/>
{ethPrice && (
<p>
ETH/USD: <strong>${Number(ethPrice).toLocaleString()}</strong>{" "}
{mood === "bullish" ? "— bullish" : "— bearish"}
</p>
)}
<p style={{ fontSize: "0.85rem", color: "#666" }}>
Dynamic NFTs use this price to change their artwork in real time.
</p>
</fieldset>
{/* --- Section 3: Mint dynamic NFT --- */}
<fieldset>
<legend>Mint Dynamic NFT</legend>
<input
value={mintContract}
onChange={(e) => setMintContract(e.target.value)}
placeholder="Dynamic NFT contract address (0x...)"
style={{ display: "block", marginBottom: "0.5rem", width: "100%" }}
/>
<input
value={mintTokenId}
onChange={(e) => setMintTokenId(e.target.value)}
placeholder="Token ID to mint"
style={{ display: "block", marginBottom: "0.5rem", width: "100%" }}
/>
<button onClick={handleMint} disabled={isPending || !mintContract || !mintTokenId || !address}>
{isPending ? "Minting..." : "Mint NFT"}
</button>
{isConfirming && <p>Waiting for confirmation...</p>}
{isSuccess && <p>NFT minted! Tx: {txHash}</p>}
{mintError && <p>Error: {mintError.message}</p>}
</fieldset>
{error && <p style={{ color: "red" }}>Error: {error}</p>}
</div>
);
}On-chain SVG NFTs — Learn
Why put art on-chain?
Most NFTs store a URI on-chain that points to an image hosted somewhere else — IPFS, Arweave, or a plain HTTP server. If the host goes away, the art disappears. On-chain SVG NFTs eliminate this dependency entirely: the artwork is generated by the smart contract itself and returned as a data URI. As long as the blockchain exists, the art exists.
Notable examples:
- Nouns — one noun is auctioned per day, with all pixel art generated on-chain via a multi-part SVG compositor (nouns.wtf)
- Autoglyphs — the first on-chain generative art on Ethereum, created by Larva Labs in 2019 (larvalabs.com/autoglyphs)
- Loot — text-only on-chain NFTs that spawned an entire derivative ecosystem
The tradeoff is gas: deploying a contract that generates SVG is expensive (2–5M gas), but reading the art via tokenURI is a free view call — forever.
How on-chain SVG works
The ERC-721 standard defines a tokenURI(uint256 tokenId) function that returns a string. For off-chain NFTs this is an IPFS or HTTP URL. For on-chain NFTs, the contract builds the response at runtime using Solidity string operations.
The encoding pipeline has three layers:
- Build raw SVG — the contract concatenates SVG elements using
abi.encodePacked:
string memory svg = string(abi.encodePacked(
'<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500">',
'<rect fill="', _getBackground(tokenId), '" width="500" height="500"/>',
'<text x="250" y="250" text-anchor="middle" font-size="80">',
_getMoodEmoji(tokenId),
'</text></svg>'
));
- Wrap in JSON — the SVG is base64-encoded and embedded in the metadata JSON:
string memory json = string(abi.encodePacked(
'{"name":"Mood NFT #', Strings.toString(tokenId),
'","description":"A dynamic SVG NFT"',
',"image":"data:image/svg+xml;base64,', Base64.encode(bytes(svg)), '"}'
));
- Return as data URI — the JSON is base64-encoded and prefixed:
return string(abi.encodePacked(
"data:application/json;base64,",
Base64.encode(bytes(json))
));
The result is a fully self-contained data URI. No external requests needed — the entire NFT (metadata + image) is in the return value.
OpenZeppelin provides a Base64 utility library that handles the encoding (docs.openzeppelin.com/contracts/5.x/utilities).
What makes an NFT dynamic
A static NFT returns the same tokenURI data for a given token ID every time. A dynamic NFT reads on-chain state inside tokenURI and returns different data based on current conditions:
function _getMoodEmoji(uint256 tokenId) internal view returns (string memory) {
int256 price = _getLatestPrice();
if (price > THRESHOLD) return unicode"😊";
return unicode"😟";
}
The contract itself never changes — the view function reads changing state and generates different output.
Common sources of dynamism:
| Trigger | Example | On-chain? |
|---|---|---|
| Time | Aging art that evolves each month | Yes (block.timestamp) |
| Price | Happy/sad face based on ETH price | Yes (Chainlink oracle) |
| Ownership | Art changes each time it's transferred | Yes (transfer count in state) |
| External event | Weather-based art | Requires oracle/keeper |
| Token balance | Art reacts to holder's portfolio | Yes (balanceOf calls) |
No transaction is needed to "update" the NFT. Anyone calling tokenURI sees the current version based on current state. Marketplaces that cache metadata may show stale versions — OpenSea provides a "Refresh metadata" button for this reason.
Chainlink price feeds
Smart contracts cannot access off-chain data directly. Oracles bridge this gap by publishing external data on-chain. Chainlink is the most widely used oracle network on EVM chains.
How it works
A network of independent node operators fetches price data from multiple exchanges, aggregates it, and posts the result to an on-chain contract. The aggregator contract stores the latest price and exposes it via latestRoundData().
The AggregatorV3Interface
For consumers, one function matters:
function latestRoundData() external view returns (
uint80 roundId, // round sequence number
int256 answer, // the price (8 decimals for USD pairs)
uint256 startedAt, // round start timestamp
uint256 updatedAt, // when the answer was last updated
uint80 answeredInRound // deprecated, matches roundId
);
Key details:
answerisint256, notuint256— prices can theoretically go negative (this happened with oil futures in 2020)- USD pairs use 8 decimals: an
answerof200000000000means $2,000.00 - ETH/BTC pairs use 18 decimals — always check the feed's
decimals()function updatedAtshould be checked for staleness — if it's older than the feed's heartbeat interval (typically 1 hour for major pairs), the data may be unreliable
Known addresses
| Feed | Network | Address |
|---|---|---|
| ETH/USD | Sepolia | 0x694AA1769357215DE4FAC081bf1f309aDC325306 |
| ETH/USD | Mainnet | 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 |
Full list: docs.chain.link/data-feeds/price-feeds/addresses
Reading and rendering on-chain SVGs from TypeScript
The decoding pipeline mirrors the encoding in reverse:
Step 1: Call tokenURI
const uri = await client.readContract({
address: contractAddress,
abi: erc721TokenURIAbi,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
// uri = "data:application/json;base64,eyJuYW1lIjoiTW9vZCBORlQgIz..."
Step 2: Decode the outer base64 layer
if (uri.startsWith("data:application/json;base64,")) {
const jsonString = atob(uri.split(",")[1]);
const metadata = JSON.parse(jsonString);
}
// metadata = { name: "Mood NFT #1", image: "data:image/svg+xml;base64,PHN2Zy..." }
Step 3: Decode the SVG from the image field
const imageUri = metadata.image;
if (imageUri.startsWith("data:image/svg+xml;base64,")) {
const svgMarkup = atob(imageUri.split(",")[1]);
}
// svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>'
Step 4: Render safely
<iframe sandbox="" srcDoc={svgMarkup} title="On-chain SVG NFT" />
Some contracts use data:image/svg+xml;utf8, (URL-encoded) instead of base64. Handle both:
if (
imageUri.startsWith("data:image/svg+xml;utf8,") ||
imageUri.startsWith("data:image/svg+xml,")
) {
const svgMarkup = decodeURIComponent(imageUri.split(",")[1]);
}
SVG rendering and security
SVGs are XML documents. Unlike raster images (PNG, JPEG), SVGs can contain executable content: <script> tags, event handlers (onload, onclick), <foreignObject> elements that embed HTML, and external resource references. An on-chain SVG from an unverified contract is untrusted input.
Three rendering approaches compared:
| Method | XSS safe? | Interactive SVG? | Complexity |
|---|---|---|---|
<img src="data:..."> | Yes (browsers block scripts) | No | Low |
<iframe sandbox="" srcDoc="..."> | Yes (sandbox blocks scripts) | Limited | Medium |
dangerouslySetInnerHTML | No | Yes | Low |
Recommendation: Use <iframe sandbox=""> with an empty sandbox attribute. This is the most restrictive mode — it blocks scripts, forms, popups, top-level navigation, and same-origin access. The SVG renders visually but cannot execute any code.
Never use dangerouslySetInnerHTML with untrusted SVGs. An <img> tag is safe but prevents SVG animations and CSS-based interactivity.
For additional protection, set a Content Security Policy header that restricts frame-src to data: URIs only.
Gas costs and practical considerations
Deployment vs read costs
Deploying a dynamic SVG NFT contract typically costs 2–5M gas depending on the SVG complexity and the amount of string manipulation logic. However, reading tokenURI is a free view call — no gas required, ever.
Compare this to a standard ERC-721 where the metadata is off-chain: deployment is cheaper (~1.5M gas) but you pay ongoing costs for IPFS pinning services and depend on their availability.
The 24KB contract size limit
EIP-170 limits deployed contract bytecode to 24,576 bytes. Complex SVG generation logic can easily hit this limit, especially if the contract stores large lookup tables for attributes, color palettes, or layered image parts.
Workarounds:
- SSTORE2 — stores arbitrary data in contract bytecode of separate "storage" contracts, reading it back cheaply via
EXTCODECOPY(github.com/0xsequence/sstore2) - Multi-contract architecture — split rendering logic across multiple contracts (Nouns uses this pattern with a separate
NounsDescriptorcontract) - Minimal SVG — keep shapes simple, reuse elements with
<use>, compress attribute names
When on-chain SVG makes sense
On-chain SVG is best for projects where permanence and trustlessness are core to the value proposition. Generative art, identity tokens, governance badges, and protocol-native assets all benefit from being fully on-chain. For complex photo-realistic art or video, off-chain storage (Arweave for permanence, IPFS for cost) remains the practical choice.