w3-kitdocs

onchain-svg-nft

Decode on-chain SVG NFTs, render base64 tokenURI metadata, and interact with dynamic NFTs that react to Chainlink price feeds.

evm

Dependencies

viem
onchain-svg-nft/evm.tsx
"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:

  1. 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>'
));
  1. 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)), '"}'
));
  1. 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:

TriggerExampleOn-chain?
TimeAging art that evolves each monthYes (block.timestamp)
PriceHappy/sad face based on ETH priceYes (Chainlink oracle)
OwnershipArt changes each time it's transferredYes (transfer count in state)
External eventWeather-based artRequires oracle/keeper
Token balanceArt reacts to holder's portfolioYes (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.

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:

  • answer is int256, not uint256 — prices can theoretically go negative (this happened with oil futures in 2020)
  • USD pairs use 8 decimals: an answer of 200000000000 means $2,000.00
  • ETH/BTC pairs use 18 decimals — always check the feed's decimals() function
  • updatedAt should 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

FeedNetworkAddress
ETH/USDSepolia0x694AA1769357215DE4FAC081bf1f309aDC325306
ETH/USDMainnet0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419

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:

MethodXSS safe?Interactive SVG?Complexity
<img src="data:...">Yes (browsers block scripts)NoLow
<iframe sandbox="" srcDoc="...">Yes (sandbox blocks scripts)LimitedMedium
dangerouslySetInnerHTMLNoYesLow

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 NounsDescriptor contract)
  • 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.