import assert from "node:assert/strict"; import { describe, it, before } from "node:test"; import { network } from "hardhat"; * What the renderer can serve, and what it costs. * Storing is linear and cheap. Rendering used to be neither: assembling the * image, base64-ing it, building the JSON and base64-ing that meant four * full-size buffers, and EVM memory is billed with the square of how far it * stretches. ColorgasmRendererV3 writes the whole document into one * exactly-sized buffer instead, so the cost is close to linear again. * Two ceilings matter, and neither is the block gas limit: * TX_CAP 16,777,216 gas — EIP-7825's per-transaction cap. Past this no * contract can call tokenURI onchain and eth_estimateGas refuses. * CALL_CAP ~50,000,000 gas — the eth_call cap on a standard node. Past this * a marketplace asking for tokenURI gets an error and the token * Runs on the `probe` network, which lifts those caps so the measurement * reaches the renderer rather than the chain. scripts/render-ceiling.js walks a * whole range of sizes; this pins the ones that matter. const TX_CAP = 16_777_216n; const CALL_CAP = 50_000_000n; describe("COLORGASM renderer — what it costs to serve a token", () => { let pre, raw; // one stack per storage shape ({ ethers } = await network.create({ network: "probe" })); [artist] = await ethers.getSigners(); // The renderer reads pointers and metadata from the extension, so no // creator contract is needed to measure this. pre = await deployStack(ethers, artist.address, { preEncoded: true }); raw = await deployStack(ethers, artist.address, { preEncoded: false }); * A renderer of its own, in a known mode. Measuring by flipping one shared * renderer between two calls made this suite depend on ambient state — if * anything else had touched it, both measurements silently read the same * mode and the comparison became meaningless. One contract per mode, with * the mode asserted before it is used, cannot go wrong that way. async function rendererIn(stack, base64Json) { const r = await ethers.deployContract("ColorgasmRendererV3", [ await stack.ext.getAddress(), await stack.blobs.getAddress(), await stack.renderer.preEncoded(), await r.waitForDeployment(); if (!base64Json) await (await r.setBase64Json(false)).wait(); assert.equal(await r.base64Json(), base64Json, `renderer should be in base64Json=${base64Json}`); /** Upload an image into a stack and return the gas its tokenURI needs. */ async function measure(stack, id, image, { preEncoded }) { const { key, chunks, gas } = await upload(stack.blobs, id, image, { preEncoded }); await stack.ext.setArtwork(id, key, ethers.toUtf8Bytes(fragment(id))) const render = await stack.renderer.tokenURI.estimateGas(artist.address, id, id); const uri = await stack.renderer.tokenURI(artist.address, id, id); return { chunks, storeGas: gas, render, uri }; it("serves a pre-encoded 100 KB image inside a single transaction's budget", async () => { const image = fakeImage(100 * 1024, 1); const { chunks, storeGas, render, uri } = await measure(pre, 1, image, { preEncoded: true }); const { imageBytes, json } = decodeTokenURI(uri); ` 100 KB pre-encoded: ${chunks} chunks, ${storeGas} gas to store, ${render} to render`, sameBytes(imageBytes, image, "the image must survive the round trip exactly"); assert.equal(json.name, "COLORGASM #001"); assert.ok(render < TX_CAP, `100 KB must render inside a transaction (${render} gas)`); it("serves a raw 100 KB image too, encoding chunk by chunk", async () => { const image = fakeImage(100 * 1024, 1); const { chunks, storeGas, render, uri } = await measure(raw, 1, image, { preEncoded: false }); const { imageBytes } = decodeTokenURI(uri); ` 100 KB raw: ${chunks} chunks, ${storeGas} gas to store, ${render} to render`, sameBytes(imageBytes, image, "chunk-local base64 must concatenate exactly"); assert.ok(render < CALL_CAP, `100 KB must render under a ~50M eth_call cap (${render} gas)`); it("produces byte-identical documents whichever way the bytes are stored", async () => { // The storage shape is an internal cost decision; the token must not be // able to tell which one was chosen. const image = fakeImage(CHUNK * 2 + 4_321, 7); const a = await measure(pre, 2, image, { preEncoded: true }); const b = await measure(raw, 2, image, { preEncoded: false }); assert.equal(a.uri, b.uri, "pre-encoded and raw must render the same document"); ` ${image.length} B: store pre-encoded ${a.storeGas} vs raw ${b.storeGas} gas ` + `(+${(((Number(a.storeGas) - Number(b.storeGas)) / Number(b.storeGas)) * 100).toFixed(0)}%), ` + `render ${a.render} vs ${b.render}`, assert.ok(a.storeGas > b.storeGas, "pre-encoded stores 4 bytes for every 3"); assert.ok(a.render < b.render, "...and renders more cheaply in exchange"); it("grows close to linearly with the image", async () => { // The shape of the fix, pinned as a test. v1 doubled-and-then-some at every // step; if this ever regresses past 2.4x, memory has crept back in. const small = await measure(pre, 3, fakeImage(64 * 1024, 3), { preEncoded: true }); const large = await measure(pre, 4, fakeImage(128 * 1024, 4), { preEncoded: true }); const ratio = Number(large.render) / Number(small.render); console.log(` 64 KB -> ${small.render}, 128 KB -> ${large.render} (x${ratio.toFixed(2)})`); assert.ok(ratio < 2.4, `doubling the image should not much more than double the gas (x${ratio.toFixed(2)})`); it("costs far less as UTF-8 than as base64 JSON", async () => { const image = fakeImage(64 * 1024, 11); await measure(pre, 5, image, { preEncoded: true }); // Two separate contracts, each fixed in one mode — nothing is mutated // between the two measurements. const asB64 = await rendererIn(pre, true); const asUtf8 = await rendererIn(pre, false); const b64 = await asB64.tokenURI.estimateGas(artist.address, 5, 5); const utf8 = await asUtf8.tokenURI.estimateGas(artist.address, 5, 5); const uri = await asUtf8.tokenURI(artist.address, 5, 5); assert.ok(uri.startsWith("data:application/json;utf8,")); (await asB64.tokenURI(artist.address, 5, 5)).startsWith("data:application/json;base64,"), const { json, imageBytes } = decodeTokenURI(uri); sameBytes(imageBytes, image, "the cheap path must carry the same image"); assert.equal(json.name, "COLORGASM #005"); assert.ok(uri.includes("%23"), "the # in the name must be percent-encoded"); assert.equal(uri.indexOf("#"), -1, "no raw # may reach a client"); // Both documents must decode to the same token, whichever wrapper is used. assert.deepEqual(decodeTokenURI(await asB64.tokenURI(artist.address, 5, 5)).json, json); console.log(` 64 KB: base64 JSON ${b64} vs utf8 ${utf8} gas`); assert.ok(utf8 < b64, `utf8 (${utf8}) should be cheaper than base64 (${b64})`); it("predicts the rendered size without rendering", async () => { for (const base64Json of [true, false]) { const r = await rendererIn(pre, base64Json); const size = await r.renderedSize(1); const actual = BigInt((await r.tokenURI(artist.address, 1, 1)).length); const drift = size > actual ? size - actual : actual - size; assert.ok(drift < 64n, `renderedSize ${size} vs actual ${actual} (base64Json=${base64Json})`); it("refuses to render a token whose artwork was never uploaded", async () => { await (await pre.ext.setArtwork(60, "0x" + "ff".repeat(32), ethers.toUtf8Bytes("x"))).wait(); pre.renderer.tokenURI(artist.address, 60, 60), (e) => e.message.includes("NothingStored"), it("renders garbage if the storage shape and the renderer disagree", async () => { // Why scripts/upload.js and scripts/point.js check preEncoded against the // deployed renderer before spending anything: preEncoded is immutable, and // a mismatch is silent — no revert, just a token that decodes to nothing. const image = fakeImage(4_000, 33); await upload(raw.blobs, 40, image, { preEncoded: false }); // stored RAW await (await raw.ext.setArtwork(40, artworkKey(40), ethers.toUtf8Bytes(fragment(40)))).wait(); // ...read by a renderer that believes the bytes are already base64 const wrong = await ethers.deployContract("ColorgasmRendererV3", [ await raw.ext.getAddress(), await raw.blobs.getAddress(), await wrong.waitForDeployment(); const uri = await wrong.tokenURI(artist.address, 40, 40); let decodedToSomethingWrong = true; const { imageBytes } = decodeTokenURI(uri); decodedToSomethingWrong = !Buffer.from(imageBytes).equals(Buffer.from(image)); // also fine — it may not even parse assert.ok(decodedToSomethingWrong, "a shape mismatch must not silently produce the right image"); // The matching renderer gets it right from the very same bytes. sameBytes(decodeTokenURI(await raw.renderer.tokenURI(artist.address, 40, 40)).imageBytes, image); it("refuses raw chunks that are not 3-aligned", async () => { // Chunk-local base64 only concatenates correctly when every chunk but the // last is a multiple of 3; a misaligned blob would render silently wrong, // so the renderer rejects it instead. const key = "0x" + Buffer.from("misaligned").toString("hex").padEnd(64, "0"); await (await raw.blobs.append(key, "0x" + "ab".repeat(100))).wait(); // 100 % 3 != 0 await (await raw.blobs.append(key, "0x" + "cd".repeat(30))).wait(); await (await raw.ext.setArtwork(61, key, ethers.toUtf8Bytes("x"))).wait(); raw.renderer.tokenURI(artist.address, 61, 61), (e) => e.message.includes("ChunkNotAligned"), it("stores linearly, whatever rendering costs", async () => { const key = "0x" + Buffer.from("linear").toString("hex").padEnd(64, "0"); const chunk = "0x" + "ab".repeat(CHUNK); const one = await (await pre.blobs.append(key, chunk)).wait(); const two = await (await pre.blobs.append(key, chunk)).wait(); const ratio = Number(two.gasUsed) / Number(one.gasUsed); console.log(` chunk 1 -> ${one.gasUsed}, chunk 2 -> ${two.gasUsed} (x${ratio.toFixed(2)})`); assert.ok(ratio > 0.9 && ratio < 1.1, `storing must stay linear, got x${ratio.toFixed(2)}`);