Skip to content
On this page

L2-19: SVG NFTs (On-Chain SVG NFTs)

1. Problem

Create NFTs whose images are stored entirely on-chain -- no IPFS, no off-chain storage. Each NFT's image (SVG XML) and metadata (JSON) are dynamically generated through Solidity string concatenation, Base64-encoded as a Data URI, and returned directly to the browser for rendering.

The core challenge: generate SVG graphics (circles, rectangles, triangles) with randomized attributes in Solidity, using a pseudo-random seed to produce unique color and shape combinations for each token.

2. Why

Most NFT projects store their images on IPFS. While IPFS is decentralized, it relies on pinning services (such as Pinata, Infura) to keep files available. If the pinning service goes down or files get garbage-collected, the NFT becomes a "blank image."

Fully on-chain NFTs encode image data directly in the contract's tokenURI() return value:

  • Permanent persistence: as long as Ethereum exists, the NFT can be rendered
  • No external dependencies: no need for IPFS/Arweave/centralized servers
  • Composability: other contracts can directly read and manipulate SVG data
  • True decentralization: no off-chain component can fail

This is also an excellent exercise in understanding Solidity string manipulation and Base64 encoding.

3. Solution

Architecture Design

mint() → tokenId + seed (pseudo-random)

tokenURI(tokenId) → generateSVG(seed) → Base64 encode
    ↓                                    ↓
    ├── SVG XML (color, shape, size)    ├── JSON metadata
    └── Base64(SVG)                     └── Base64(JSON)

                              data:application/json;base64,...

Core Implementation

SVG Generation (dynamic attributes):

solidity
function _generateSVG(uint256 tokenId, uint256 seed) private pure returns (string memory) {
    // Derive color from seed
    uint256 r = uint256(keccak256(abi.encodePacked(seed, "red"))) % 256;
    uint256 g = uint256(keccak256(abi.encodePacked(seed, "green"))) % 256;
    uint256 b = uint256(keccak256(abi.encodePacked(seed, "blue"))) % 256;

    // Derive shape parameters from seed
    uint256 shape = seed % 3;    // 0=circle, 1=rectangle, 2=triangle
    uint256 cx = (seed % 200) + 50;
    uint256 cy = ((seed >> 16) % 200) + 50;
    uint256 size = ((seed >> 32) % 60) + 20;
    uint256 opacity = ((seed >> 48) % 30) + 70; // 70-99%

    string memory shapeSvg = _generateShape(shape, cx, cy, size, r, g, b, opacity);

    return string(abi.encodePacked(
        '<svg xmlns="http://www.w3.org/2000/svg" width="400" height="400" viewBox="0 0 400 400">',
        '<rect width="400" height="400" fill="#1a1a2e"/>',
        shapeSvg,
        '<text x="20" y="380" font-size="10" fill="#ffffff" opacity="0.3">OnChainSVGNFT</text>',
        '</svg>'
    ));
}

Base64 JSON Metadata:

solidity
function _buildJSON(uint256 tokenId, string memory svg) private pure returns (string memory) {
    return string(abi.encodePacked(
        Base64.encode(bytes(abi.encodePacked(
            '{"name":"OnChainSVG #', tokenId.toString(),
            '","description":"Fully on-chain SVG NFT","image":"data:image/svg+xml;base64,',
            Base64.encode(bytes(svg)),
            '"}'
        )))
    ));
}

Pseudo-Random Seed

solidity
uint256 seed = uint256(keccak256(abi.encodePacked(
    block.timestamp, msg.sender, tokenId
)));

Note: this is pseudo-random (predictable) and is not suitable for scenarios requiring true randomness. Use Chainlink VRF for production.

4. Pitfalls Encountered

4.1 Quote Escaping in JSON

The JSON returned by tokenURI() contains nested quotes. Since Solidity strings use double quotes, double quotes inside JSON must be escaped with backslashes. Incorrect escaping causes JSON parsing failures, making the NFT invisible on marketplaces.

4.2 Double Nesting of Base64 Encoding

The SVG XML itself needs Base64 encoding, then the entire JSON metadata is Base64-encoded again. data:image/svg+xml;base64,<Base64(SVG)> → this Data URI is embedded in the "image" field of the JSON → the entire JSON is Base64-encoded → prefixed with data:application/json;base64,.

4.3 Gas Cost of String Concatenation

Each abi.encodePacked creates a new byte array in memory. For SVGs with substantial text (e.g., 800+ bytes), multiple concatenations can cost 50K-100K gas. Although tokenURI is a view function that doesn't cost users gas, it affects the contract's read performance.

4.4 No Floating-Point Support in Solidity

opacity in SVG is a decimal between 0 and 1 (e.g., opacity="0.8"), but Solidity has no floating-point numbers. You must simulate decimals using integer arithmetic (e.g., opacity = 80 → "0.80").

5. Why the Pitfalls Happen

5.1

Solidity's string type has no native escape mechanism. Developers must manually embed \" in strings. This is the most common source of bugs -- one missing backslash and the entire JSON is invalid.

5.2

Data URI format: data:[<mediatype>][;base64],<data>. The mediatype for SVG is image/svg+xml; for JSON it is application/json. Double Base64 encoding ensures that no special characters break the URL structure.

5.3

The cost of Solidity memory operations depends on data size. Each abi.encodePacked call allocates new memory. For complex SVGs, consider using multiple smaller concatenation steps to optimize memory usage.

6. How to Resolve the Pitfalls

  • Use OpenZeppelin's Strings.toString() and Base64.encode() libraries to simplify implementation
  • For opacity, store integer values (0-100) and convert to "0.XX" format during SVG generation
  • Test tokenURI return values: copy the output into a browser to verify JSON validity and SVG rendering
  • In production, consider storing SVG templates in constant strings (compile-time evaluated, zero runtime cost)

7. Technical Highlights

Key PointDescription
On-chain SVGSolidity XML string concatenation + Base64 encoding
Data URIdata:image/svg+xml;base64,...
Double Base64 nestingSVG → Base64 → JSON → Base64
OpenZeppelin utilitiesStrings.toString() + Base64.encode()
Pseudo-random seedkeccak256(block.timestamp, msg.sender, tokenId)
Dynamic attributescircle/rectangle/triangle + RGB color + size
Gas optimizationabi.encodePacked and constant templates

Built with AiAda