From ec0a2ef48e4a48231e7e642a45a00f5ab50e0610 Mon Sep 17 00:00:00 2001 From: Jadonamite Date: Thu, 22 Jan 2026 02:39:20 +0400 Subject: [PATCH] feat/dynamic-nft-tutorial feat(tutorials): add dynamic on-chain NFT guide --- docs/cookbook/dynamic-nft-tutorial.md | 275 ++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/cookbook/dynamic-nft-tutorial.md diff --git a/docs/cookbook/dynamic-nft-tutorial.md b/docs/cookbook/dynamic-nft-tutorial.md new file mode 100644 index 00000000..49ee68b4 --- /dev/null +++ b/docs/cookbook/dynamic-nft-tutorial.md @@ -0,0 +1,275 @@ +This tutorial covers building a **fully on-chain Dynamic NFT**. Unlike static JPEGs on IPFS, this NFT’s appearance and metadata are generated entirely in Solidity. + +We will build **"The Base Bloom"**: A flower that grows from a **Seed** → **Sprout** → **Flower** based on on-chain time. + +--- + +# Tutorial: Building a Dynamic On-Chain NFT + +### **Objective** + +Create an ERC-721 token that updates its own metadata and SVG image based on `block.timestamp`. + +### **Architecture** + +* **Storage:** The contract stores the "birth time" of each token. +* **Logic:** The `tokenURI` function calculates the current "Stage" (0, 1, or 2) by comparing `block.timestamp` to the birth time. +* **Rendering:** The contract dynamically constructs a Base64-encoded SVG string representing the current stage. No IPFS or API is required. + +--- + +## **Prerequisites** + +* Foundry +* Base Sepolia ETH + +--- + +## **Part 1: The Smart Contract** + +We need `OpenZeppelin` for the ERC721 standard and Base64 encoding. + +**Setup:** + +```bash +forge init dynamic-nft +cd dynamic-nft +forge install OpenZeppelin/openzeppelin-contracts --no-commit + +``` + +**`foundry.toml` Remappings:** + +```toml +remappings = [ + "@openzeppelin/=lib/openzeppelin-contracts/" +] + +``` + +**Contract: `src/BaseBloom.sol**` + +This contract uses a "Stage" logic. + +* **Stage 0 (Seed):** Age < 1 hour. +* **Stage 1 (Sprout):** 1 hour < Age < 24 hours. +* **Stage 2 (Bloom):** Age > 24 hours. + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/utils/Base64.sol"; +import "@openzeppelin/contracts/utils/Strings.sol"; + +contract BaseBloom is ERC721 { + using Strings for uint256; + + uint256 private _nextTokenId; + + // Mapping from Token ID to Mint Timestamp + mapping(uint256 => uint256) public birthTime; + + constructor() ERC721("BaseBloom", "BLOOM") {} + + function mint() public { + uint256 tokenId = _nextTokenId++; + _mint(msg.sender, tokenId); + birthTime[tokenId] = block.timestamp; + } + + /** + * @notice Determines the growth stage based on time elapsed. + * 0 = Seed, 1 = Sprout, 2 = Bloom + */ + function getStage(uint256 tokenId) public view returns (uint256) { + // Validation check + _requireOwned(tokenId); + + uint256 age = block.timestamp - birthTime[tokenId]; + + // For demo purposes: stages happen fast (60 seconds and 120 seconds) + // In prod: use larger values (e.g., 1 days, 7 days) + if (age < 60) { + return 0; // Seed + } else if (age < 120) { + return 1; // Sprout + } else { + return 2; // Bloom + } + } + + /** + * @notice Generates the SVG image code for the current stage. + */ + function generateSVG(uint256 tokenId) internal view returns (string memory) { + uint256 stage = getStage(tokenId); + string memory color; + string memory element; + + if (stage == 0) { + color = "#8B4513"; // Brown Soil + element = ''; // A small seed + } else if (stage == 1) { + color = "#228B22"; // Green Sprout + element = ''; + } else { + color = "#FFD700"; // Golden Bloom + element = ''; + } + + return string(abi.encodePacked( + '', + '', + element, + 'Base Bloom #', tokenId.toString(), '', + '' + )); + } + + /** + * @notice Returns the full Data URI (Base64 encoded JSON + SVG). + */ + function tokenURI(uint256 tokenId) public view override returns (string memory) { + _requireOwned(tokenId); + + string memory svg = Base64.encode(bytes(generateSVG(tokenId))); + string memory stageName = getStage(tokenId) == 0 ? "Seed" : (getStage(tokenId) == 1 ? "Sprout" : "Bloom"); + + string memory json = Base64.encode(bytes(string(abi.encodePacked( + '{"name": "Base Bloom #', tokenId.toString(), '",', + '"description": "A dynamic flower that grows on Base.",', + '"attributes": [{"trait_type": "Stage", "value": "', stageName, '"}],', + '"image": "data:image/svg+xml;base64,', svg, '"}' + )))); + + return string(abi.encodePacked("data:application/json;base64,", json)); + } +} + +``` + +--- + +## **Part 2: Deployment** + +**`script/DeployBloom.s.sol`** + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Script.sol"; +import "../src/BaseBloom.sol"; + +contract DeployBloom is Script { + function run() external { + uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY"); + vm.startBroadcast(deployerPrivateKey); + + BaseBloom bloom = new BaseBloom(); + console.log("BaseBloom deployed at:", address(bloom)); + + vm.stopBroadcast(); + } +} + +``` + +**Deploy:** + +```bash +forge script script/DeployBloom.s.sol --rpc-url $BASE_SEPOLIA_RPC --broadcast --verify --etherscan-api-key $BASESCAN_API_KEY + +``` + +--- + +## **Part 3: Frontend Integration (The Viewer)** + +Since the metadata is on-chain, fetching it is slightly different. The `tokenURI` returns a raw base64 string, not an HTTP URL. + +**Component: `components/BloomViewer.tsx**` + +```tsx +'use client'; + +import { useState, useEffect } from 'react'; +import { useReadContract } from 'wagmi'; + +const CONTRACT_ADDRESS = "0xYourDeployedAddress"; + +const ABI = [{ + "inputs": [{"name": "tokenId", "type": "uint256"}], + "name": "tokenURI", + "outputs": [{"name": "", "type": "string"}], + "stateMutability": "view", + "type": "function" +}] as const; + +export default function BloomViewer({ tokenId }: { tokenId: bigint }) { + const [image, setImage] = useState(""); + const [name, setName] = useState(""); + + // Read the Token URI + const { data: tokenUriData, refetch } = useReadContract({ + address: CONTRACT_ADDRESS, + abi: ABI, + functionName: 'tokenURI', + args: [tokenId], + }); + + useEffect(() => { + if (tokenUriData) { + // 1. Data comes in as "data:application/json;base64,ey..." + // 2. We need to split logic to handle the prefix + const base64String = tokenUriData.split(',')[1]; + + try { + // 3. Decode the JSON + const jsonString = atob(base64String); + const metadata = JSON.parse(jsonString); + + // 4. Set Image and Name + setName(metadata.name); + setImage(metadata.image); // This is already a data:image/svg+xml string + } catch (e) { + console.error("Error parsing metadata", e); + } + } + }, [tokenUriData]); + + return ( +
+
+ {image ? ( + Base Bloom + ) : ( +
+ )} +
+
+

{name || `Loading #${tokenId}...`}

+

This NFT updates automatically over time.

+
+ +
+
+
+ ); +} + +``` + +--- + +## **Common Pitfalls** + +1. **Gas Cost:** Storing huge strings in Solidity is expensive. Keep your SVG generation logic minimal. Use simple shapes (``, ``). +2. **Base64 Padding:** Solidity's `Base64` libraries are strict. Ensure you use the OpenZeppelin one, as it handles padding correctly. +3. **Marketplace Caching:** Opensea and other marketplaces cache metadata heavily. They often won't show the "Bloom" stage immediately even if the contract says so. You often have to click "Refresh Metadata" on the marketplace manually. +* *Dev Tip:* For purely dynamic visuals in a DApp, always read directly from the contract (as shown in the frontend code), rather than relying on a secondary indexer API. +