EVM × Sui Walrus GitHub
Showcase 06 · implemented

10 000 NFTs in two Walrus Quilts. No per-token pinning.

A vanilla OpenZeppelin ERC-721 whose tokenURI(id) deterministically returns <aggregator>/v1/blobs/by-quilt-id/<quiltId>/<id>.json. The contract stores one quiltId; a small TypeScript packer turns a drop directory into one Walrus operation per layer (images, then metadata). No 10 000 pins, no OpenSea-only reliability tier, no per-token state.

Contract: QuiltedCollection.sol CLIs: 06-quilted-collection/ Tools: walrus CLI · Foundry · TypeScript
01 · the pain

OpenSea's 99.2% reliability bump came from centralizing on Pinata.

decentralization theater
ipfs-pain.md · §8
"OpenSea actually improved their NFT metadata reliability by 99.2% when they switched to Pinata's infrastructure" — i.e. the centralization tax for collection drops is enormous because each token needs its own pin.
— 2026 builder-sentiment survey
02 · the shape

Two quilts, one contract, deterministic per-token URL.

A metadata JSON's image field has to be a fully-qualified aggregator URL, which has to include the quiltId of the quilt it points at. If you put images and metadata in the same quilt, the metadata's content depends on the quilt's id, but the quilt's id is content-addressed from the metadata — circular.

The clean fix is two quilts: one for images (QID_IMAGES), one for metadata (QID_METADATA). Each metadata JSON references the images quilt's id (known after pass one); the contract holds the metadata quilt's id (known after pass two).

On-chain tokenURI shape is fully deterministic from (aggregator, quiltId, tokenId) — indexers and marketplaces can resolve tokens off-chain without an EVM RPC call.

// QuiltedCollection.sol
string public quiltId;       // base64url, set in constructor
string public aggregator;    // owner-mutable for sunset migration
uint256 public immutable maxSupply;

function mint() external returns (uint256 tokenId);
function tokenURI(uint256 id) public view returns (string memory) {
    _requireOwned(id);
    return string.concat(
      aggregator, "/v1/blobs/by-quilt-id/", quiltId, "/", id.toString(), ".json"
    );
}
drop/
store-quilt ×2
contract(QID_META)
03 · try it locally

From a drop directory to a deployed contract in six commands.

01

Prepare two drop directories

images/
├── 1.png
├── 2.png
└── ... up to maxSupply.png

meta/
├── 1.json   # image = "<AGG>/v1/blobs/by-quilt-id/<QID_IMAGES>/1.png"
├── 2.json
└── ...

For purely on-chain rendering (no images), skip the images quilt entirely and just pack the metadata.

02

Install + pack the images quilt

cd showcases/06-quilted-collection
pnpm install

# long-lived collection — bump from the default 200 epochs if needed
export WALRUS_EPOCHS=200

pnpm pack:quilt --dry-run ./images   # dry run prints the CLI invocation
pnpm pack:quilt ./images
# → quiltId: <base64url>    (this is QID_IMAGES)

Requires the walrus CLI on PATH. Install per docs.wal.app/usage/setup.html.

03

Generate metadata JSONs with the images quilt's URL baked in

Write each meta/<id>.json so its image field is the aggregator URL for the matching PNG:

{
  "name": "Walrus Quilted #1",
  "description": "...",
  "image": "https://aggregator.walrus-testnet.walrus.space/v1/blobs/by-quilt-id/<QID_IMAGES>/1.png"
}

How you author these is up to you — a 20-line script, a templating engine, or a one-off sed. The key is that the metadata files are written exactly once, with the final image URLs already baked in.

04

Pack the metadata quilt

pnpm pack:quilt ./meta
# → quiltId: <base64url>    (this is QID_METADATA)
05

Deploy the contract against QID_METADATA

# constructor args (forwarded to DeployQuiltedCollection.s.sol via env)
export QC_NAME="Walrus Quilted #01"
export QC_SYMBOL="WQ01"
export QC_QUILT_ID=<QID_METADATA from step 4>
export QC_AGGREGATOR="https://aggregator.walrus-testnet.walrus.space"
export QC_MAX_SUPPLY=10000

export EVM_RPC_URL="http://127.0.0.1:8545"
export DEPLOYER_PRIVATE_KEY="0xac0974…"   # anvil dev-0

pnpm deploy:contract --dry-run   # prints the forge command, private key redacted
pnpm deploy:contract             # real broadcast
EnvRequired?Purpose
QC_NAME / QC_SYMBOLyesERC-721 metadata.
QC_QUILT_IDyesThe metadata quilt id from step 4.
QC_AGGREGATORyesCanonical aggregator host; can be migrated via setAggregator.
QC_MAX_SUPPLYyesPositive integer; mint caps at this.
EVM_RPC_URL / DEPLOYER_PRIVATE_KEYyesStandard forge-script args.
06

Resolve token URLs off-chain

export AGGREGATOR="https://aggregator.walrus-testnet.walrus.space"
export QUILT_ID=<QID_METADATA>

pnpm url 42
# → https://aggregator.walrus-testnet.walrus.space/v1/blobs/by-quilt-id/<...>/42.json

Or as a library — useful for marketplaces and indexers that don't want an EVM RPC round-trip per token:

import { tokenURI } from "./src/url";
tokenURI({ aggregator, quiltId, tokenId: 42n });
04 · verify

What "good" looks like.

05 · what this is NOT

Limits to set expectations.

  • Not a launchpad. No allowlist, no reveal mechanic, no royalty enforcement, no merkle airdrop. Mint is open and self-mint, capped at maxSupply.
  • Not a metadata authoring tool. The drop directory layout (<id>.json + <id>.png) is your responsibility.
  • Not a re-uploader. Once a Quilt is sealed, the contents are immutable. Bug in a metadata JSON? Pack a new Quilt and either redeploy or use setAggregator to point at a different resolver that serves the fixed Quilt.
  • Not a hash verifier. Aggregator-served bytes are gateway-trust-equivalent. For trustless retrieval add a Reed-Solomon recompute via @mysten/walrus on top.
06 · source files

Where to look in the repo.