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.
"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
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" ); }
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.
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.
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.
pnpm pack:quilt ./meta # → quiltId: <base64url> (this is QID_METADATA)
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
| Env | Required? | Purpose |
|---|---|---|
| QC_NAME / QC_SYMBOL | yes | ERC-721 metadata. |
| QC_QUILT_ID | yes | The metadata quilt id from step 4. |
| QC_AGGREGATOR | yes | Canonical aggregator host; can be migrated via setAggregator. |
| QC_MAX_SUPPLY | yes | Positive integer; mint caps at this. |
| EVM_RPC_URL / DEPLOYER_PRIVATE_KEY | yes | Standard forge-script args. |
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 });
cast call $CONTRACT "quiltId()(string)" returns the base64url id from step 4.cast call $CONTRACT "tokenURI(uint256)(string)" 1 matches the pnpm url 1 output.image field inside the metadata JSON resolves to the matching PNG (the images quilt URL).setAggregator; the on-chain quiltId stays the same.maxSupply.<id>.json + <id>.png) is your responsibility.setAggregator to point at a different resolver that serves the fixed Quilt.@mysten/walrus on top.maxSupply + mutable aggregator.
contracts/test/QuiltedCollection.t.sol10 Foundry tests covering constructor validation, sold-out cap, tokenURI shape, setAggregator authorization.
contracts/script/DeployQuiltedCollection.s.solEnv-driven forge script — QC_* vars become constructor args.
06-quilted-collection/src/pack.tsWraps walrus store-quilt --paths <dir>; tolerant stdout parser for the quiltId.
06-quilted-collection/src/deploy.tsForge-script wrapper that forwards QC_* env vars; redacts the private key in --dry-run output.
06-quilted-collection/src/url.tsTS port of the on-chain tokenURI — for off-chain consumers.
showcases/06-quilted-collection/README.mdFull walkthrough including the two-quilt rationale.