Mint an NFT with media + metadata on 0G Storage
Mint an ERC-721 token whose metadata JSON and media file both live
on 0G Storage rather than IPFS or AWS S3. tokenURI(id) returns
0g-storage://<root> where <root> is the Merkle root returned by the
storage upload — the chain bookmarks the storage location, never the
content itself.
By the end you'll have a Foundry-deployed ERC-721, typed TypeScript clients generated from the ABI, and a runnable mint flow that uploads two files and submits one on-chain transaction.
What you're building
$ pnpm dev 0xRecipient "Genesis" ./genesis.png
Media uploaded: 0xabc… (tx 0xfeed…)
Metadata uploaded: 0xdef… (tx 0xcafe…)
Minted to 0xRecipient: tx 0xmint…
Mint OK.
media : 0g-storage://0xabc…
metadata : 0g-storage://0xdef…
tx : 0xmint…
The recipient now owns an ERC-721 whose tokenURI resolves to a metadata
JSON containing the title, a description, and a pointer to the media
file. All of it is durable on 0G Storage.
Prerequisites
- Node 20.10 or newer.
- A funded
galileotestnet key (gas + storage segments). - Foundry installed:
curl -L https://foundry.paradigm.xyz | bash foundryup - About 25 minutes.
Scaffold
npm create 0gkit-app@latest my-nft -- --template nft-with-storage
cd my-nft
cp .env.example .env
Fill in .env:
NETWORK=galileo
PRIVATE_KEY=0x… # gas + storage signer
RPC_URL=https://evmrpc-testnet.0g.ai # Foundry deploy target
NFT_ADDRESS= # filled in after deploy
Walk the contract
contracts/StorageNFT.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract StorageNFT {
error NotOwner();
error AlreadyMinted();
string public name;
string public symbol;
address public owner;
uint256 public nextId;
mapping(uint256 => address) private _owners;
mapping(uint256 => bytes32) private _roots;
event Transfer(address indexed from, address indexed to, uint256 indexed id);
constructor(string memory n, string memory s) {
name = n;
symbol = s;
owner = msg.sender;
}
function mint(address to, bytes32 metadataRoot) external returns (uint256 id) {
if (msg.sender != owner) revert NotOwner();
id = ++nextId;
_owners[id] = to;
_roots[id] = metadataRoot;
emit Transfer(address(0), to, id);
}
function ownerOf(uint256 id) external view returns (address) {
return _owners[id];
}
function tokenURI(uint256 id) external view returns (string memory) {
bytes32 root = _roots[id];
return string(abi.encodePacked("0g-storage://", _toHex(root)));
}
// … (transfer, approval — omitted here for brevity, see the template)
function _toHex(bytes32 b) internal pure returns (string memory) {
bytes memory out = new bytes(66);
out[0] = "0";
out[1] = "x";
bytes16 alphabet = 0x30313233343536373839616263646566;
for (uint256 i = 0; i < 32; i++) {
out[2 + 2 * i] = alphabet[uint8(b[i]) >> 4];
out[2 + 2 * i + 1] = alphabet[uint8(b[i]) & 0x0f];
}
return string(out);
}
}
Key choices:
- Inline ERC-721 (not OpenZeppelin) so a tutorial reader can read the
whole contract top-to-bottom. In production, use
@openzeppelin/contracts. See "Production hardening" below. mint(to, metadataRoot)takes the metadata root as abytes32, not a string URI. Smaller calldata, cheaper gas, type-safe.tokenURIprefixes with0g-storage://— a URI scheme that's not resolvable by browsers directly. We'll address that with a gateway in "Production hardening".
Build the contract
pnpm build:contracts
# → calls `forge build`, writes out/StorageNFT.sol/StorageNFT.json
Generate the typed TS client
pnpm generate:contracts
# → calls `0g contracts generate --abi out/StorageNFT.sol/StorageNFT.json --out src/generated`
This produces src/generated/StorageNFT.ts — a deterministic TypeScript
module with full IntelliSense on every contract method:
// src/generated/StorageNFT.ts (generated)
export const StorageNFTAbi = [
/* … typed ABI … */
] as const;
export type StorageNFT = {
read: {
name(): Promise<string>;
symbol(): Promise<string>;
ownerOf(id: bigint): Promise<`0x${string}`>;
tokenURI(id: bigint): Promise<string>;
// …
};
write: {
mint(args: [to: `0x${string}`, metadataRoot: `0x${string}`]): Promise<Receipt>;
};
events: {
/* … */
};
};
You get autocomplete on read.tokenURI(...), type errors when you pass
the wrong argument shape, and the runtime path goes through viem with the
SP4 createTypedContract wrapper.
Deploy
forge script scripts/Deploy.s.sol \
--rpc-url $RPC_URL --broadcast --private-key $PRIVATE_KEY
The script prints Deployed to: 0x…. Paste that address into NFT_ADDRESS
in your .env.
Walk the mint flow — src/mint-flow.ts
import { Storage } from "@foundryprotocol/0gkit-storage";
import { createTypedContract } from "@foundryprotocol/0gkit-contracts";
import { StorageNFTAbi } from "./generated/StorageNFT";
import { encodeMetadata } from "./metadata";
export async function runMintFlow(
input: {
to: `0x${string}`;
title: string;
mediaBytes: Uint8Array;
mediaContentType: string;
},
deps: { storage: Storage; nft: ReturnType<typeof createTypedContract> }
): Promise<MintResult> {
// 1. Upload media.
const { root: mediaRoot, tx: mediaTx } = await deps.storage.upload(input.mediaBytes);
// 2. Build + upload metadata referencing the media root.
const metadataBytes = encodeMetadata({
name: input.title,
description: `0G-native NFT for ${input.title}`,
image: `0g-storage://${mediaRoot}`,
contentType: input.mediaContentType,
});
const { root: metadataRoot, tx: metaTx } = await deps.storage.upload(metadataBytes);
// 3. Mint on chain, passing the metadata root.
const { txHash } = await deps.nft.write.mint([input.to, metadataRoot]);
return {
kind: "ok",
media: mediaRoot,
metadata: metadataRoot,
mediaTx: mediaTx.txHash,
metaTx: metaTx.txHash,
mintTx: txHash,
};
}
The flow is intentionally three sequential steps because each one depends on the previous: metadata references the media root; the mint references the metadata root. There's no way to parallelize without breaking the dependency chain.
For very large media (multi-MB), the upload latency is the long pole. Consider pre-uploading media before the user finishes choosing options, then committing the mint when they confirm.
Run it
pnpm dev 0xRecipient "Genesis" ./genesis.png
You should see three transactions: media upload, metadata upload, mint. Each prints its hash. The final line shows the mint receipt.
Verify on chain:
cast call $NFT_ADDRESS \
"tokenURI(uint256)(string)" 1 \
--rpc-url $RPC_URL
# → 0g-storage://0xdef…
That's the metadata root. Resolve it:
0g storage get 0xdef… - | jq
# → { "name": "Genesis", "description": "…", "image": "0g-storage://0xabc…", … }
Marketplace gateway
Marketplaces like OpenSea expect tokenURI to be HTTPS-resolvable. The
0g-storage://<root> scheme isn't — it points at a content-addressed
storage layer the browser doesn't understand.
The solution is a read-through gateway: a tiny HTTP service that
resolves 0g-storage:// to JSON.
// gateway/api/[root].ts
import { Storage } from "@foundryprotocol/0gkit-storage";
const storage = new Storage({ network: "galileo" });
export async function GET(req, { params }) {
const bytes = await storage.download(params.root as `0x${string}`);
return new Response(bytes, {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
Then update your contract's tokenURI to return an HTTPS URL:
function tokenURI(uint256 id) external view returns (string memory) {
return string(abi.encodePacked(
"https://nft-gateway.your-domain.com/api/",
_toHex(_roots[id])
));
}
Two design notes:
- Set
Cache-Control: immutable. A storage root is content-addressed — its content can never change. Browsers and CDNs can cache forever. - The gateway is read-only. No keys, no signers, no privileged ops. Deploy on Vercel / Cloudflare Workers / a static $5 box. It's a CDN proxy with a fancier resolver.
Production hardening
Use OpenZeppelin's ERC-721
The inline contract is for legibility, not safety. Replace with OZ:
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract StorageNFT is ERC721, Ownable {
mapping(uint256 => bytes32) private _roots;
uint256 public nextId;
constructor() ERC721("Storage NFT", "SNFT") Ownable(msg.sender) {}
function mint(address to, bytes32 metadataRoot) external onlyOwner returns (uint256 id) {
id = ++nextId;
_safeMint(to, id);
_roots[id] = metadataRoot;
}
function tokenURI(uint256 id) public view override returns (string memory) {
_requireOwned(id);
return string(abi.encodePacked("https://nft-gateway.your-domain.com/api/", Strings.toHexString(uint256(_roots[id]), 32)));
}
}
OZ gives you audited safeTransferFrom, approval logic, owner enumeration,
the IERC721 interface, and EIP-165 support — months of subtle gotchas
already solved.
Durable uploads
A 50-MB media upload shouldn't block a request handler. Move uploads onto
@foundryprotocol/0gkit-jobs:
const uploadJobId = await runner.enqueue({
name: "upload-media",
input: { mediaBytes, mediaContentType },
});
// Return jobId to the client. Webhook fires when upload completes.
The webhook handler then enqueues the metadata upload + mint. See durable jobs concept for the full pattern.
Cost estimation
Storage uploads cost gas + fee per 256-KiB segment. Estimate before mint:
const est = await storage.estimate(input.mediaBytes.length);
console.log(`Estimated cost: ${est.fee} wei across ${est.breakdown.segments} segments`);
if (est.fee > userBudget) throw new Error("upload too expensive");
For a CLI smoke test:
0g estimate storage ./genesis.png
Royalty enforcement (ERC-2981)
Marketplaces query ERC-2981's royaltyInfo(tokenId, salePrice) to read
royalty terms. OZ has a base class:
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract StorageNFT is ERC721, ERC2981, Ownable {
constructor() ERC2981() { _setDefaultRoyalty(owner, 500); /* 5% */ }
// … remember to override supportsInterface for ERC2981 + ERC721
}
Observability
Instrument once, get spans for every storage upload and every mint transaction:
import { instrument0g } from "@foundryprotocol/0gkit-observability";
await instrument0g({ serviceName: "nft-minter", exporter: { kind: "otlp" } });
The mint flow produces a span tree per call: root span per runMintFlow,
child spans per storage.upload (with 0gkit.size_bytes,
0gkit.segments), child span for the contract mint
(0gkit.gas_native, 0gkit.tx_hash).
What you built
You have:
- An ERC-721 contract whose metadata and media live on 0G Storage — off-chain, content-addressed, durable.
- Typed TypeScript clients generated from your Foundry artifact.
- A two-upload, one-mint flow that's auditable end-to-end.
- A gateway design that makes the NFT marketplace-compatible without giving up storage neutrality.
- Production wiring: OZ contracts, durable uploads, royalties, observability.
See also
@foundryprotocol/0gkit-storage— upload + download.@foundryprotocol/0gkit-contracts— typed contracts and codegen.0g contractsCLI —generate,list,info.- Durable jobs concept — for moving uploads off the request path.