Fully On-Chain SVG NFTs: Putting the Artwork Inside the Contract

· 10 min read · web3 solidityfoundryerc721nftsvgbase64onchain

Every NFT has two halves. The token itself — balances, approvals, ownership — lives in the contract and is as permanent as the chain. The artwork is a different story. tokenURI() returns a string, and for most collections that string points somewhere else: an ipfs:// CID, an HTTPS URL. The token is permanent. The thing it points to is a dependency nobody’s contract controls.

This project is a learning repo, not production work — the README says exactly that — and everything here ran against the Sepolia testnet. I am writing it up because the fully on-chain pattern is genuinely useful, the numbers below are real, and the code is small enough to read in one sitting.

How do you mint an NFT whose artwork cannot disappear?

If you search that question as a new Solidity developer, most answers route you to IPFS with a shrug about pinning. There is a better answer for small art: put the art in the contract, encoded, and hand the wallet a complete data: URI that contains the metadata JSON and the image in one string. Then the artwork is just bytes on the chain, no different from the token itself.

There are two ways to do it. You can store the raw SVG in the contract and base64-encode it every time tokenURI() is called — cheaper to deploy, a little more gas per read. Or you can encode the SVG once at deploy time and store the finished data:image/svg+xml;base64, strings. My MoodNft does the second, because it also flips between two images from on-chain state, and the whole thing is a gentle introduction to dynamic NFTs.

What I tried first: an ERC-721 that stores a URI

The starting point is the conventional NFT contract. Mine is BasicNft:

contract BasicNft is ERC721 {
    error BasicNft__TokenUriNotFound();
    uint256 private s_tokenCounter;
    mapping(uint256 => string) private s_tokenIdToUri;

    constructor() ERC721("Hoelee", "HOE") {
        s_tokenCounter = 0;
    }

    function mintNft(string memory tokenUri) public {
        s_tokenIdToUri[s_tokenCounter] = tokenUri;
        _safeMint(msg.sender, s_tokenCounter);
        s_tokenCounter = s_tokenCounter + 1;
    }

    function tokenURI(
        uint256 tokenId
    ) public view override returns (string memory) {
        if (ownerOf(tokenId) == address(0)) {
            revert BasicNft__TokenUriNotFound();
        }
        return s_tokenIdToUri[tokenId];
    }
}

It is deliberately tiny. mintNft(tokenUri) stores whatever string you hand it, per token; tokenURI returns that string; a custom error fires if the token was never minted (cheaper than a string require, and self-documenting). I named the collection after myself, because the point was learning with a straight face: ERC721("Hoelee", "HOE").

The contract has no opinion about what the URI points to — neither does OpenZeppelin’s ERC721 base — so the art lives wherever the URI lives.

The honest problem with IPFS-hosted art

For BasicNft the flow is the standard one: put a metadata JSON on IPFS, hand the contract its ipfs://<CID>, and let wallets and marketplaces resolve it through a gateway. Rendering works as long as two things stay true: someone keeps a pin of that CID alive, and some gateway keeps serving it. Both are outside anyone’s contract. If the pin drops, the token still exists — it just has no art.

My own mint script is exhibit A of how sloppy this gets. Interactions.s.sol carries four example URIs: two raw ipfs:// CIDs and two https://gateway.pinata.cloud/ipfs/... URLs. The README’s own note on the topic says to prefer the raw CID form “so the asset resolves independent of any single gateway.” A gateway URL is a single point of failure, full stop. The raw CID is better, but it still depends on a pin existing somewhere.

And the saved mint log shows what actually got stored for token 0 of BasicNft:

ipfs://QmW1aRxvAngY22wrxyrUYSriekkHQMcXA3D1mjHgBc5ge6?filename=MrHoelee.png

That CID is a pointer to a pin I did not run. I cannot promise that node — or whoever pins it now — stays up. That gap between “the token is permanent” and “the art is a promise” is exactly what I wanted to remove.

The fix: encode the artwork at deploy time

MoodNft stores the art itself. The deploy script does the encoding, reading the two SVG files from img/ and turning each into a data:image/svg+xml;base64, URI before the constructor is even called:

function run() external returns (MoodNft) {
    string memory svgSmile = vm.readFile("img/smile.svg");
    string memory svgSad = vm.readFile("img/sad.svg");
    string memory imageUriSmile = svgToImageUri(svgSmile);
    string memory imageUriSad = svgToImageUri(svgSad);

    vm.startBroadcast();
    MoodNft moodNft = new MoodNft(imageUriSmile, imageUriSad);
    vm.stopBroadcast();

    return moodNft;
}

function svgToImageUri(
    string memory svg
) public pure returns (string memory) {
    string memory baseURL = "data:image/svg+xml;base64,";
    string memory svgBase64Encoded = Base64.encode(
        bytes(string(abi.encodePacked(svg)))
    );

    return string(abi.encodePacked(baseURL, svgBase64Encoded));
}

Two details matter. vm.readFile is a Foundry cheatcode that reads a file from disk during the script run, so the contract never contains a base64 blob that was hand-generated and pasted — the art on chain is provably the art in img/. And Base64 comes from OpenZeppelin’s utils, so I am not writing an encoder myself.

The constructor then stores both ready-made URIs as state:

constructor(
    string memory happySvgImageUri,
    string memory sadSvgImageUri
) ERC721("MoodNft", "MN") {
    s_tokenCounter = 0;
    s_sadSvgImageUri = sadSvgImageUri;
    s_happySvgImageUri = happySvgImageUri;
}

mintNft() takes no URI at all: it _safeMints, records the new token as Mood.HAPPY in a mapping(uint256 => Mood), and increments a counter. The art was decided at deploy time, not at mint time.

tokenURI builds the whole NFT on the fly

This is the heart of the pattern:

function tokenURI(
    uint256 tokenId
) public view override returns (string memory) {
    string memory imageURI;
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        imageURI = s_happySvgImageUri;
    } else {
        imageURI = s_sadSvgImageUri;
    }

    string memory tokenMetadata = string.concat(
        '{"name":"',
        name(), // You can add whatever name here
        '", "description":"An NFT that reflects the mood of the owner, 100% on Chain!", ',
        '"attributes": [{"trait_type": "moodiness", "value": 100}], "image":"',
        imageURI,
        '"}'
    );

    string memory tokenURIJson = string(
        abi.encodePacked(
            _baseURI(),
            Base64.encode(
                bytes(abi.encodePacked(tokenMetadata))
            )
        )
    );
    return tokenURIJson;
}

function _baseURI() internal pure override returns (string memory) {
    return "data:application/json;base64,";
}

Walk through what that returns: the "image" field inside the JSON is itself a data:image/svg+xml;base64, URI — the art, not a pointer to art. The whole metadata JSON is then base64-encoded, and _baseURI() prepends data:application/json;base64,, so tokenURI() returns one self-contained string. A wallet decodes it and has the name, the description, the attributes and the image bytes with nothing left to fetch. There is no IPFS, no HTTPS, no gateway in the entire chain of custody.

flipMood: a dynamic NFT from on-chain state

Because the mood is state, changing state changes the art:

function flipMood(uint256 tokenId) public {
    if (
        getApproved(tokenId) != msg.sender && ownerOf(tokenId) != msg.sender
    ) {
        revert MoodNft__NotOwnerOfToken();
    }
    if (s_tokenIdToMood[tokenId] == Mood.HAPPY) {
        s_tokenIdToMood[tokenId] = Mood.SAD;
    } else {
        s_tokenIdToMood[tokenId] = Mood.HAPPY;
    }
}

The gate is owner or approved address — getApproved() comes free from ERC721 — and anyone else gets MoodNft__NotOwnerOfToken(). Flip it, and tokenURI starts returning the other image for the same token id. Same token, different face, all of it on chain. That is the smallest possible dynamic NFT, and a good place to start thinking about art driven by game state or on-chain events.

Solidity details the tests taught me

You cannot == two strings in Solidity. Solidity only compares value types; string is a dynamic array of bytes. The idiomatic fix is comparing hashes, and my test file even documents it in a comment:

// string is array of bytes can't directly compare
// we can compare: bool, uint256, address, bytes32
assert(
    keccak256(abi.encodePacked(expectedName)) ==
        keccak256(abi.encodePacked(actualName))
);

keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)) appears all over the test suite, and it is the pattern to reach for whenever you must compare strings on-chain.

The test layout is two layers. Counting the test functions in the repo gives six tests across four test contracts. MoodNftTest is a pure unit test: it constructs MoodNft directly with constant base64 URIs. The other three — DeployMoodNftTest, BasicNftTest, and MoodNftIntegrationTest — instantiate the deploy script and call deployer.run(), so they exercise the real path: vm.readFile on the actual img/*.svg, encode, deploy. That means even the “unit” deploy test is proving the base64 pipeline against the real art files.

Impersonation is two cheatcodes. makeAddr("HOELEE") is a deterministic fake address derived from a label — no keypair to manage. vm.prank(USER) makes the next call look like it comes from that address. Together they drive the happy path without a wallet:

function testFlipTokenToSad() public {
    vm.prank(USER);
    moodNft.mintNft();

    vm.prank(USER);
    moodNft.flipMood(0);

    assertEq(
        keccak256(abi.encodePacked(moodNft.tokenURI(0))),
        keccak256(abi.encodePacked(SAD_SVG_URI))
    );
}

Mint scripts should find the contract, not hardcode it. The Interactions.s.sol mint script uses DevOpsTools.get_most_recent_deployment("BasicNft", block.chainid), which reads the broadcast/ logs and returns the latest deployment of that contract on the current chain:

address mostRecentDeployed = DevOpsTools.get_most_recent_deployment(
    "BasicNft",
    block.chainid
);
mintNftOnContract(mostRecentDeployed);

My own script still carries the old hardcoded Sepolia and Anvil addresses in comments — evidence of the habit this pattern exists to kill. Get the address from the deployment logs, or you will mint a stale contract someday.

What I’d do differently

On-chain art costs deploy gas, forever. Every byte of both SVGs is paid for once, at deploy time, and then lives in the contract’s storage as constructor strings. It is a one-time cost, but it is real, and it scales with the size of the art.

There is an upper bound on how much art fits. EIP-170 caps a contract’s code at 24,576 bytes, and the art bytes share that budget with the logic. Vector SVGs like the smiley and the sad face are ideal — small, crisp, and they scale. A photograph or a 3D model will never fit, and trying to cram realistic art in there is a waste of gas. My BasicNft deploy returned 4102 bytes of code; the MoodNft bytecode has two SVGs stacked on top of that.

Not every project should do this. If the art is large, if the collection needs mutable metadata, or if deploy budget matters, IPFS or a file store with a settable base URI is the pragmatic default — and ERC721’s _baseURI() hook makes that pattern easy too. The right question to ask is: must this art survive the death of every pinning service? If yes and it fits on chain, go on-chain. Otherwise, don’t pay for the bytes.

Pin the SVG line endings. The repo pins img/*.svg to LF via .gitattributes so the encoded bytes are deterministic; on Windows, core.autocrlf would otherwise inject CRLF and silently change the encoded art. That is exactly the kind of invisible bug that this pattern — “the art is the bytes” — makes unforgiving.

Use DevOpsTools from day one. Hardcoded addresses in comments are fine for a solo testnet learner; they are a trap the moment a second deploy happens.

The quantified result

The repo keeps one deployment log, and it is for BasicNft on Sepolia, so these figures are exactly as recorded:

ItemValue
Chain11155111
Constructor trace[868596] → new BasicNft
Deployed bytecode4102 bytes of code
Deployment tx993568 gas * 0.538650187 gwei = 0.000535185588997216 ETH
Block6522146
Deployed at0x84F0Ee970BD49FCf1b8Cd637EF4e4755DBE74e0E, auto-verified on Etherscan

The mint that stored the IPFS URI for token 0 cost 181874 gas — 0.000186204671471742 ETH. So the price of putting an NFT on a testnet is tiny; the price of the IPFS dependency is invisible until the pin dies.

And the result that matters for MoodNft needs no log at all: add the deployed contract and token ID 0 as a collectible in MetaMask, and the artwork renders straight from the on-chain base64 SVG — no IPFS gateway, no network lookup, nothing to keep alive. For the same token id, flipMood changes the face. The only “server” the art depends on is the blockchain itself, and a full copy of that runs on any node.

Want this kind of work?

I am a full-stack web developer and DevOps engineer. On-chain demos like this one are where I learn, but the work I do for businesses is full-stack web development — websites, web apps, APIs, and the self-hosted stacks and deploy pipelines behind them. If you need a project built from scratch, or a small on-chain proof of concept to validate an idea, tell me what you are trying to ship: WhatsApp · [email protected] · hoelee.com.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts