a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/test/render.test.js
JavaScript · 238 lines · 11 KBRAWDOWNLOAD
import assert from "node:assert/strict";
import { describe, it, before } from "node:test";
import { network } from "hardhat";
import {
CHUNK,
artworkKey,
decodeTokenURI,
deployStack,
fakeImage,
fragment,
sameBytes,
upload,
} from "./helpers.js";
/**
* 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
* renders as broken.
*
* 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 ethers, artist;
let pre, raw; // one stack per storage shape
before(async () => {
({ 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}`);
return r;
}
/** 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 (
await stack.ext.setArtwork(id, key, ethers.toUtf8Bytes(fragment(id)))
).wait();
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);
console.log(
` 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);
console.log(
` 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");
console.log(
` ${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,"));
assert.ok(
(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();
await assert.rejects(
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(),
true,
]);
await wrong.waitForDeployment();
const uri = await wrong.tokenURI(artist.address, 40, 40);
let decodedToSomethingWrong = true;
try {
const { imageBytes } = decodeTokenURI(uri);
decodedToSomethingWrong = !Buffer.from(imageBytes).equals(Buffer.from(image));
} catch {
// 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();
await assert.rejects(
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)}`);
});
});