A 200-line dApp that shows how to swap the storage layer behind a vanilla OpenZeppelin ERC-721. The Solidity contract
stores a plain tokenURI string — identical in shape to an IPFS-backed NFT. Two write paths drop the
same kind of string in; the EVM side never knows which one was used.
"Don't trust free decentralized storage to outlive your project. Bankless's Metaversal newsletter now routinely frames IPFS as 'data must be pinned through services like ClubNFT or Pinata to be maintained.'"— 2026 builder-sentiment survey
The contract is an OpenZeppelin ERC721 + ERC721URIStorage. mint(string) assigns a
token id and stores the string verbatim. There is no Walrus-specific code on chain.
The browser uploads bytes to either a local Next.js API route that signs Sui transactions
with an operator keypair (default), or directly to the public Walrus testnet publisher
(set NEXT_PUBLIC_WALRUS_UPLOAD_MODE=publisher). Both paths return the same 32-byte
blobId and lead to the same aggregator URL.
The migration cost from IPFS is changing one URL prefix. The Solidity side does not move.
// the entire EVM-side contract surface function mint(string tokenURI_) external returns (uint256); function mintTo(address to, string tokenURI_) external returns (uint256); event Minted( uint256 indexed tokenId, address indexed minter, string tokenURI_ );
Node 22, pnpm 10, Foundry, and an injected EVM wallet (MetaMask / Rabby / Coinbase Wallet extension). No real ETH needed — the deploy script funds itself from Anvil dev-0.
nvm use (or install node@22 any other way)corepack enable && corepack prepare pnpm@10.32.1 --activatecurl -L https://foundry.paradigm.xyz | bash && foundryupBackend (default) upload mode also needs a Sui testnet keypair plus testnet SUI and testnet WAL. Publisher mode skips all Sui-side setup.
git clone git@github.com:MystenLabs/evm-sui.git cd evm-sui pnpm install cp showcases/01-evmwal-nft/web/.env.local.example showcases/01-evmwal-nft/web/.env.local
Edit web/.env.local: paste SUI_PRIVATE_KEY=suiprivkey… for backend mode, or set NEXT_PUBLIC_WALRUS_UPLOAD_MODE=publisher to skip the Sui side entirely.
# terminal A — repo root pnpm dev:chain # anvil on 127.0.0.1:8545
Anvil pre-funds 10 deterministic wallets; the first one is what deploy:local uses below.
# terminal B — inside showcase 01 cd showcases/01-evmwal-nft pnpm deploy:local # → forge script DeployEvmWalNFT.s.sol → broadcast # → write-deployed-address.ts → .deployed-address # → extract-abi.ts → web/lib/contract.ts + web/.env.local
The script reads the Foundry broadcast artifact, captures the deployed address, and patches it into the web app's env + ABI. Re-run any time you redeploy.
pnpm dev:web # http://localhost:3000
In MetaMask: add the Anvil network (chain id 31337, RPC http://127.0.0.1:8545), switch to it, then open http://localhost:3000. Hit the Mint tab, pick an image, watch the upload + mint flow. The card appears in All NFTs and My NFTs within seconds of confirmation.
Blob Move object you (the operator) own — you can extend its end_epoch or transfer it on chain./api/walrus/upload is unauthenticated. It spends operator WAL on every successful request. Run on a local dev server only.epochs: 5 (~14 days) is the default; operator picks the retention window and pays upfront.1..totalSupply on every render — snappy below ~100 tokens; for real scale you'd want a subgraph or a Transfer event indexer.@mysten/walrus on top.mint, mintTo, Minted event.
contracts/test/EvmWalNFT.t.sol7 Foundry tests, 100% line coverage.
contracts/script/DeployEvmWalNFT.s.solFoundry deploy script — Anvil-friendly defaults.
web/lib/walrus-upload.tsBrowser-side publisher PUT (fallback path).
web/lib/walrus-upload-backend.tsBrowser-side POST to the local API route (default path).
web/app/api/walrus/upload/handler.tsServer-side writeBlob via @mysten/walrus, ~30 lines, dep-injected for unit tests.
web/hooks/useMint.tsMulti-stage mint state machine; dispatches publisher vs. backend at module load.
showcases/01-evmwal-nft/README.mdFull walkthrough + comparison table with IPFS-backed NFTs.