Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Copy-paste code snippets for common web3 tasks. Each recipe has EVM and Solana i
| fetch-nft-collection | NFTs |
| buy-nft | NFTs |
| display-nft-metadata | NFTs |
| onchain-svg-nft | NFTs |
| swap-tokens | DeFi |
| provide-liquidity | DeFi |
| stake-tokens | DeFi |
Expand Down
53 changes: 53 additions & 0 deletions recipes/onchain-svg-nft/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# On-chain SVG NFT

Decode and render fully on-chain SVG NFTs, read Chainlink price feeds, and mint dynamic NFTs whose metadata changes based on real-world data.

## What this does

Given an ERC-721 contract address and token ID, reads the base64-encoded `tokenURI`, decodes the JSON metadata, extracts the embedded SVG image, and renders it safely in a sandboxed iframe. Optionally reads a Chainlink price feed to demonstrate how dynamic NFTs react to on-chain state changes.

## On-chain vs off-chain NFT storage

| | Fully on-chain (SVG) | Off-chain (IPFS/Arweave) | Off-chain (HTTP) |
| -------------- | ------------------------------------------ | ---------------------------------- | -------------------------------- |
| **Storage** | SVG + JSON in contract bytecode | URI on-chain, data off-chain | URI on-chain, data on server |
| **Permanence** | Permanent — survives as long as chain runs | IPFS: fragile / Arweave: perm. | Server can go down |
| **Dynamic?** | Yes — contract generates metadata per call | No — content-addressed = immutable | Yes — server can change response |
| **Gas cost** | High (deploy) / Free (read) | Low (just store URI) | Low (just store URI) |
| **Examples** | Nouns, Autoglyphs, Loot | Bored Apes, Azuki | Early/low-budget projects |

## How dynamic NFTs work

A dynamic NFT contract generates its `tokenURI` response at read time instead of storing a fixed URI. The contract reads on-chain state (prices, timestamps, ownership history) and constructs different SVG/JSON based on that state. The metadata changes without any transaction — a view call returns different data each time conditions change.

## Why EVM only

On-chain SVG generation via `abi.encodePacked` inside `tokenURI()` is an EVM-specific pattern. Solana NFTs use Metaplex, which stores a URI pointing off-chain — it does not generate art inside the program. Chainlink price feeds are also EVM-native. For Solana oracle patterns, see the Pyth network documentation.

## Security notes

- **SVG XSS** — on-chain SVGs can contain `<script>` tags or event handlers; always render in a sandboxed iframe with `sandbox=""` (maximum restriction)
- **Price feed staleness** — check `updatedAt` from Chainlink; stale data (> 1 hour old) may indicate a feed issue
- **Contract verification** — verify the NFT contract on Etherscan before interacting; a malicious `tokenURI` could return crafted payloads

## Run the showcase

```bash
cd recipes/onchain-svg-nft/example
npm install
npm run dev
```

Open http://localhost:3333 — built on the `templates/recipe-shell/` with w3-kit design system and components.

Features:

- **Read On-chain SVG** — fetches Loot NFTs from mainnet, renders SVG + w3-kit NFTCard
- **Chainlink Price Feed** — live ETH/USD via w3-kit PriceTicker
- **Deploy & Mint** — deploy a DynamicSvgNft contract on Sepolia, mint, read back

## Files

- `evm.tsx` — Reusable component: decode on-chain SVG, read Chainlink price, mint
- `example/` — Showcase app (TanStack Start + w3-kit design system)
- `onchain-svg-nft.learn.md` — Educational deep-dive (~2500 words)
282 changes: 282 additions & 0 deletions recipes/onchain-svg-nft/evm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
"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>
);
}
39 changes: 39 additions & 0 deletions recipes/onchain-svg-nft/example/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"name": "w3-kit-recipe-shell",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-router": "^1.121.3",
"@tanstack/react-start": "^1.121.3",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.7.0",
"nitro": "^3.0.260311-beta",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"viem": "^2.21.0",
"wagmi": "^2.14.0"
},
"overrides": {
"h3": "2.0.1-rc.20",
"h3-v2": "npm:h3@2.0.1-rc.20"
},
"devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3",
"vite": "^7.3.2"
}
}
Loading
Loading