a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/README.md
Markdown · 484 lines · 23 KBREADSOURCERAWDOWNLOAD

COLORGASM — a fully onchain edition, built as a Manifold extension

COLORGASM (CLGSM) is a 100-piece edition minted on a Manifold creator contract, with its metadata and images hosted on Arweave. This repository is the machinery that moves the artworks themselves into Ethereum contract code, and lets each holder swap their Arweave-era token for a fully onchain one.

It is three small Solidity contracts, a set of Node scripts that drive them, and the Python encoder that turns a 60 MB PNG into an AVIF small enough to live onchain and sharp enough to still be the artwork.

The interesting part — and the reason this README exists — is that the creator contract could not be changed. Everything here is built inside the one extension point Manifold gives you.


Deployed on Ethereum mainnet

contractaddressrole
COLORGASM (CLGSM) creator0x7dC2a81Ba2Bf47548EC2F9645007119dF90ba01bthe Manifold ERC-721 creator contract — unchanged, not deployed from this repo
ColorgasmRedeemExtension0xCD5ef63FA405786B2ecb498F83687f6a566CF374the extension: burn/redeem, artwork pointers, tokenURI
ColorgasmStorage0xE43f8FAC380616bf15968be038eF00Bc01514c00the artwork bytes, as SSTORE2 chunks
ColorgasmRendererV30x57a2e9e9a327483A18a37E29F6EF36b96C6Fb5b6assembles the token document

Deployed at block 25,963,689, source verified on Etherscan. Live configuration: artwork stored raw (the renderer base64s it at read time), document served as data:application/json;utf8,. The same three addresses are recorded in deployments/mainnet.json, which every script in this repo reads instead of taking addresses on the command line.


1. The constraint: a contract you cannot change

CLGSM is a Manifold creator contract — a proxy onto Manifold's ERC-721 Creator Core. The original 100 tokens were minted through Manifold's edition extension, and their tokenURI is an Arweave prefix plus the token index. That contract is immutable: there is no admin function that rewrites those URIs, no upgrade path, no way to reach inside and replace what token #57 points at.

So a fully onchain COLORGASM cannot be a migration of the old tokens. It has to be a new token that the holder chooses to mint, by destroying the old one — and that new token has to get its tokenURI from somewhere the creator contract is willing to ask.

That "somewhere" is an extension.


2. What a Manifold extension actually is

This is the core mechanism, and it is simpler than it sounds. Manifold's Creator Core has a registry of extension contracts. An extension is an ordinary contract you deploy yourself. Once the creator's owner or admin calls

creator.registerExtension(address(extension), "");

three things become true:

1. The extension can mint. creator.mintExtension(to) mints a new token on the creator contract and records that this extension minted it (creator.tokenExtension(tokenId)). The token is a real CLGSM token: same contract, same collection, same royalties, same everything a marketplace sees.

2. The creator asks the extension for the token's metadata. On every tokenURI(tokenId) call, Creator Core looks up which extension minted that token and, via ERC-165, asks whether that extension implements ICreatorExtensionTokenURI:

interface ICreatorExtensionTokenURI {
    function tokenURI(address creator, uint256 tokenId) external view returns (string memory);
}
// interfaceId == bytes4(keccak256("tokenURI(address,uint256)"))

If it does, the creator delegates — it calls back into the extension and returns whatever string comes out. That single delegation is the whole hinge of this project: it is the only way to make an immutable creator contract serve a tokenURI that is computed from bytes stored in code, and it applies only to tokens the extension itself minted. The old tokens are untouched.

3. The extension can burn, with permission. creator.burn(tokenId) works for an owner-or-approved caller, so the extension can destroy an original — but only once the holder has approved it. Nothing here can take a token.

What an extension cannot do is equally important: it cannot change the tokenURI of tokens it did not mint, it cannot alter the creator's royalties or name, and it cannot register itself. Registration is a deliberate act by the creator's admin wallet.

The call graph

        HOLDER                                          MARKETPLACE / anyone
          │                                                      │
          │ 1. approve(extension, id)                            │ tokenURI(newId)
          │ 2. burnRedeem(id)                                    ▼
          ▼                                          ┌──────────────────────┐
┌─────────────────────────┐   burn(id)               │  CLGSM creator core  │
│ ColorgasmRedeemExtension │ ───────────────────────► │  (Manifold, immutable)│
│                         │   mintExtension(holder)   └──────────┬───────────┘
│  artworkKeyOf[id] ──────┼──┐                                   │ ERC-165 check,
│  metadataOf[id]         │  │                    tokenURI(creator, newId)
│  originalOf[newId]      │  │                                   │ delegated back
└──────────┬──────────────┘  │                        ◄──────────┘
           │ renderer        │ bytes32 key
           ▼                 ▼
┌────────────────────────┐  ┌─────────────────────────┐
│  ColorgasmRendererV3   │─►│    ColorgasmStorage     │
│  builds the document   │  │  SSTORE2 chunks per key │
└────────────────────────┘  └─────────────────────────┘

3. Three contracts, one job each

The split is not decoration. Each contract exists because something about it has to be able to change independently of the others — or to never change at all.

ColorgasmRedeemExtension — authority and bookkeeping

The Manifold-facing contract, and the only one the creator contract knows about. It holds:

  • creator, firstOriginalId, editionSize — immutable, set at deployment (CLGSM, 1, 100).
  • artworkKeyOf[originalTokenId] → a bytes32 storage key. Not the image — just where it lives.
  • metadataOf[originalTokenId] → the token's original JSON minus the image fields, stored via SSTORE2. Each redeemed token keeps exactly the name, description and attributes it was minted with.
  • originalOf[newTokenId] / redeemedTokenOf[originalTokenId] — the mapping between the burned original and its onchain replacement, in both directions.
  • renderer and blobStorage — swappable addresses, until they are locked.

Its two public entry points are burnRedeem(originalTokenId) and burnRedeemBatch(uint256[]). Both check isRedeemable, check ownerOf, burn, mint, and record. One transaction, ~130k gas, no data arguments — the holder never sends the artwork, because the artwork is already onchain.

isRedeemable(id) is true only when all five conditions hold: an artwork pointer is set, a metadata fragment is set, the token is not already redeemed, a renderer is configured, and a storage contract is configured. Surface it in any frontend rather than letting holders guess.

ColorgasmStorage — the bytes

A deliberately generic, permissionless blob store. Bytes are written under a caller-chosen bytes32 key, one chunk per transaction, as SSTORE2 — the data is deployed as contract code, which is the cheapest persistent storage the EVM offers: 200 gas per byte, against 625 for a regular storage slot.

append(bytes32 key, bytes calldata chunk) returns (uint256 index)
seal(bytes32 key)                    // irreversible
chunksOf(bytes32 key) returns (address[])   // what a renderer reads

Two design points worth understanding:

  • The first address to write a key owns it. Nobody can overwrite someone else's blob, and anyone can use the contract for their own work. There is no admin.
  • MAX_CHUNK is 24,573 bytes — SSTORE2's 24,575-byte limit rounded down to a multiple of 3 (3 × 8191). The multiple of 3 is what lets base64 be done chunk by chunk at render time: 3 bytes become exactly 4 characters, so 3-aligned chunks encode independently and their encodings concatenate with no padding in the middle.

seal(key) is the promise a collector actually cares about: after it, those bytes can never change, by anyone, ever.

ColorgasmRendererV3 — presentation, and the only swappable part

Reads the pointer and metadata fragment from the extension, the chunk pointers from storage, and builds

data:application/json;utf8,{"name":"COLORGASM #057",…,"image":"data:image/avif;base64,…"}

in one exactly-sized buffer, allocated once, every byte written into its final place and never moved again. Earlier versions built the image, then its base64, then the JSON, then sometimes a base64 of that — four full-size buffers, and the EVM prices memory with the square of how far it stretches. At these image sizes that difference is the difference between a token that renders and one that does not.

Three choices it encodes:

  • Raw vs pre-encoded. preEncoded is immutable per deployment. Raw stores the AVIF bytes and base64s them at read time (cheapest to store). Pre-encoded stores the base64 characters and renders by straight extcodecopy (33% more to store, 15–20× cheaper to serve). Both produce byte-identical documents. Mainnet runs raw.
  • UTF-8 vs base64 JSON. Serving the JSON as UTF-8 rather than base64-ing it again removes the last full-size copy — at 64 KB that alone is 280k gas against 5.15M. Switchable with setBase64Json.
  • Percent-encoding. Every COLORGASM name contains a #, which starts a URI fragment and would truncate the document at the name. The metadata fragment is escaped; base64 output is URI-safe throughout.

The renderer is token-agnostic — it holds no per-token state — so replacing it is a single setRenderer() call, until lockRenderer().


4. Two numbers that shaped everything

A transaction cannot use more than 16,777,216 gas. EIP-7825 caps it, whatever the block gas limit is. A maximum-size SSTORE2 chunk costs ~5.53M, so one chunk per transaction is the comfortable shape — and the reason EthFS and ScriptyStorageV2 work the same way. This is why the artwork never passes through the extension: uploading is 41 ordinary transactions for the largest piece, and pointing a token at it afterwards is one ~140k-gas call, however large the artwork.

An eth_call gets roughly 50,000,000 gas on a standard node. That is the budget a marketplace has when it asks for tokenURI. Past it the call errors and the token shows as broken. Measured with npm run ceiling, on synthetic images at the sizes of the real encodes:

imagestore pre-encodedstore rawrender pre-encodedrender raw
116 KB35.0M26.2M0.4M7.0M
250 KB75.3M56.5M1.2M15.4M
400 KB120.5M90.3M2.7M25.6M
600 KB180.6M—5.8M—
964 KB290.1M—14.2M—

Every COLORGASM encode renders inside that budget — including the 964 KB one, and not merely within an eth_call but inside a single transaction.


5. The life of one artwork, end to end

CLGSM-57.png                 60 MB, 4320 × 7680
   │  encode/encode_onchain_v2.py        AVIF 4:4:4, constant quality, 1 MB cap
   ▼
COLORGASM_#57.avif           ~600 KB
   │  scripts/upload.js                  25 chunks of 24,573 B, one per tx
   ▼
ColorgasmStorage             key = bytes32("COLORGASM_57"), then seal()
   │  scripts/point.js                   setArtwork(57, key, metadata)
   ▼
ColorgasmRedeemExtension     isRedeemable(57) == true
   │  holder: approve + burnRedeem(57)   original burned, new token minted
   ▼
tokenURI(newId)              creator delegates → extension → renderer
   ▼
data:application/json;utf8,{…,"image":"data:image/avif;base64,…"}

The storage key is readable, not a hash: the ASCII of COLORGASM_<id>, right-padded into a bytes32. Anyone can recompute it and read the bytes back themselves — see §9.

Nothing about this order is global. Every token is independent: its own key, its own pointer, its own moment of becoming redeemable. The artist can do one piece completely, stop for a month, and do another. Holders can redeem finished pieces while the rest are still being uploaded.


6. Getting a 60 MB PNG under a megabyte

encode/encode_onchain_v2.py turns the source PNGs into the AVIF files that go onchain. It is Pillow and NumPy only — no shell-out to avifenc, no build dependency.

The policy is constant quality, capped: every piece is encoded at one quantiser (--q, default 40) so the collection sits on a single visual tier, and fits down only when a heavy piece would exceed --budget (default 1,000,000 bytes). Light pieces land around 0.4–0.6 MB; only the heavy ones pay the cap. Bytes are spent where the content needs them, not spread evenly.

Getting there cheaply is a rate model: log(bytes) as a low-order polynomial in the quantiser index, fitted on fast speed-7 probes, then shifted to pass through the real speed-4 encode. That turns what was seven full encodes per image into one, typically with one or two probes. Metrics (PSNR, edge-energy retention, gradient PSNR) are streamed in row bands, so a 33 Mpx image does not need 2.4 GB of float64 copies.

Every codec decision in the file header was measured at matched byte size, and the ones that lost are listed there too — 4:2:0 (+7% bytes), 10-bit (+2%), tune=iq (+12%), rav1e (+14% and 4× slower), closed-loop residual compensation (+115%). The one thing that wins for this material is a light pre-sharpen (unsharp radius 0.6, 50%): +2.5 points of edge energy for 1.7% more bytes. Edge sharpness is the whole point of these images.

The actual collection, from encode/report.csv:

encodes99 (piece #66 not yet encoded)
source4320 × 7680 each
total59.2 MiB
per file114 KB min, 613 KB mean, 963 KB max
at the 1 MB cap18 of 99 (encoded below q40)
mean PSNR36.4 dB
raw chunks2,577 — i.e. 2,577 upload transactions

encode/convert.sh is the small companion that produces the pre-encoded (base64) form, with a byte-for-byte round-trip check. encode/MANIFEST.sha256 is the checksum of every encode as it was uploaded.


7. What it costs

Measured all-in, with calldata and transaction overhead: ~226 gas per byte of image stored raw, ~301 gas per byte stored pre-encoded.

AVIF sizechunkspre-encoded@1 gweiraw@1 gwei
116 KB734.4M0.034 ETH26.2M0.026 ETH
250 KB1475.3M0.075 ETH56.5M0.057 ETH
400 KB23120.5M0.121 ETH90.3M0.090 ETH
964 KB41288.6M0.289 ETH218M0.218 ETH

For the collection as encoded (99 files, 59.2 MiB):

gas@0.5 gwei@1 gwei@3 gwei
pre-encoded18.26B9.13 ETH18.26 ETH54.77 ETH
raw13.70B6.85 ETH13.70 ETH41.09 ETH

Pointing all 99 tokens afterwards is ~142k gas each — around 0.014 ETH for the lot, a rounding error beside the uploads. Redeeming is paid by the holder: ~130k gas, one transaction.

npm run estimate prices your files rather than these round numbers, by deploying a real ColorgasmStorage on a throwaway local chain and measuring each one. Run it before committing to anything — sealing is permanent.


8. Running it

npm ci                    # not `npm install` — see below
cp .env.example .env      # add ALCHEMY_KEY (or INFURA_KEY) and PRIVATE_KEY
npm run build
npm test                  # 38 tests, no RPC, no keys

Use npm ci and keep package-lock.json. Every dependency is pinned to an exact version, because gas figures and EDR behaviour move between patch releases and the numbers above were measured against exactly these versions.

npm test stands up a real Manifold ERC721Creator in-process from fixtures/ERC721Creator.json, so the extension is exercised against the genuine creator core rather than a mock: wiring, storage, pointers, redeem, batch redeem, all three locks, renderer swapping, and a byte-exact round trip of a multi-chunk image through SSTORE2 → base64 → tokenURI. npm run test:fork repeats the flow against the real CLGSM contract on a mainnet fork, with the real admin and a real current holder.

The fixture is frozen bytecode rather than a source dependency on purpose: compiling creator-core from npm drags in OpenZeppelin, and the combination is version-fragile. Dropping it took the install from 873 packages to 128.

The scripts

All of them read addresses from deployments/<network>.json (override with CREATOR= / STORAGE= / EXTENSION=) and a token range from IDS=57,3,12 or FROM_ID/TO_ID.

scriptwhat it does
deploy.jsstorage + extension + renderer, wired and recorded
verify.jsEtherscan source verification for all three
register-extension.jsregisterExtension on the creator, from its admin wallet
fetch-metadata.jsbuilds the per-token metadata fragments from the original Arweave JSON
upload.jsphase 1a — one chunk per tx, byte-verified, sealed, resumable
point.jsphase 1b — setArtwork(id, key, metadata), one small tx each
redeem.jsphase 2 — approve + burnRedeem / burnRedeemBatch
inspect.jsaudits keys, seals, bytes and the live tokenURI against local files
estimate.jsprices phase 1 per file from a folder of encodes
render-ceiling.jsmeasures store and render cost across image sizes
deploy-creator.jsa Manifold ERC721Creator on a testnet (refuses mainnet)
seed-originals.jsmints Arweave-era stand-ins on a testnet (refuses mainnet)

Two properties worth relying on:

  • upload.js is resumable and verifies bytes, not sizes. A key whose stored bytes already match the file is skipped; one that stopped part-way continues from the chunk it reached. Every chunk is read back with eth_getCode and compared against the file, so a re-encode that happens to land on the same byte count is never silently accepted. It also waits rather than sending above MAX_GWEI, so it is safe to leave running for days.
  • The measuring scripts refuse to run on a live chain. estimate.js and render-ceiling.js measure by really deploying and really sending, so they check for chainId 31337 first and stop. A live network is only ever read from, for its current gas price.

RUNBOOK.md is the full procedure — local, Sepolia rehearsal, mainnet, then freezing — including what to do when something goes wrong.

Note that AVIF_DIR defaults to ../onchain (the layout of the artist's working folder, where the encoder writes). Point it wherever your encodes live.


9. Verifying it yourself

None of this asks you to trust the deployment record. For any token:

  1. The storage key is the ASCII of COLORGASM_<id>, right-padded into a bytes32.
  2. ColorgasmStorage.chunksOf(key) returns the SSTORE2 pointer addresses in order, and isSealed(key) tells you whether they can ever change.
  3. eth_getCode on each pointer returns a leading STOP byte followed by the raw data. Strip the first byte, concatenate in order — that is the AVIF file.
  4. Its SHA-256 should match the line for that piece in encode/MANIFEST.sha256.

scripts/inspect.js does exactly this, and with DUMP_DIR=dump writes the images it decoded out of the live tokenURI to disk so you can look at them.


10. What is permanent

In increasing order of finality:

actioneffectreversible?
storage.appendwrites bytesno, but harmless — an orphaned blob costs nothing
extension.setArtworkpoints a token at a keyyes, until lockStorage
extension.setRendererchanges presentationyes, until lockRenderer
renderer.setBase64JsonJSON wrapperyes, always (swap the renderer)
storage.seal(key)freezes a key's bytesnever
extension.lockStorage()freezes every pointer, fragment and the storage addressnever
extension.lockRenderer()freezes presentationnever
burnRedeemburns an originalnever — and it is the holder's call, not the artist's

lockStorage() is the guarantee that matters to a collector: the art can no longer change. lockRenderer() is deliberately left until much later — it is the only thing that allows fixing presentation, changing the MIME type, or moving to a cheaper renderer.


Repository layout

contracts/
  ColorgasmRedeemExtension.sol   the Manifold extension
  ColorgasmStorage.sol           SSTORE2 blob storage
  ColorgasmRendererV3.sol        single-pass document assembly
  interfaces/IColorgasm.sol      Manifold + internal interfaces
scripts/                         deploy, upload, point, redeem, inspect, measure
test/                            local (28), render (10), mainnet fork (7)
fixtures/ERC721Creator.json      Manifold creator core, frozen bytecode, tests only
deployments/                     recorded addresses per network
encode/
  encode_onchain_v2.py           PNG → AVIF, constant quality, capped
  convert.sh                     AVIF → base64 (pre-encoded storage shape)
  report.csv                     per-file sizes and quality metrics
  MANIFEST.sha256                checksums of the uploaded encodes
RUNBOOK.md                       the full operational procedure

.env is gitignored and contains only provider keys, a deployer key and an Etherscan key; nothing in this repository needs it to be read.


License

MIT. See LICENSE. The contracts carry SPDX-License-Identifier: MIT.

The artworks are not licensed by this repository — the code is MIT, the images are not.