import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { network } from "hardhat"; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); /** * Largest chunk ColorgasmStorage accepts: SSTORE2's 24575-byte limit rounded * down to a multiple of 3 (3 x 8191). * * The multiple of 3 matters when artwork is stored RAW: base64 turns each 3 * bytes into 4 characters, so 3-aligned chunks encode independently and their * encodings concatenate exactly — which is what lets ColorgasmRendererV3 encode * one chunk at a time instead of holding the whole image in memory. */ export const CHUNK = 24573; /** * One chunk per transaction. A max-size chunk costs ~5.53M gas and EIP-7825 * caps a transaction at 16,777,216 however big the block is, so there is no * room for two — and no reason to want it, since the artwork never passes * through the extension. */ /** EIP-7825 per-transaction gas cap (2**24). */ export const TX_GAS_CAP = 16_777_216n; /** The Manifold creator holding COLORGASM on mainnet. */ export const CLGSM_MAINNET = "0x7dC2a81Ba2Bf47548EC2F9645007119dF90ba01b"; /** * Only what we actually call on a Manifold creator contract. * * Deliberately not the compiled ERC721Creator ABI: CLGSM on mainnet is a proxy * onto an older creator-core than the npm package (that one has no * `totalSupply`, for instance). Every entry below exists in both, so the same * ABI drives the local chain, Sepolia and mainnet. */ export const CREATOR_ABI = [ "function name() view returns (string)", "function symbol() view returns (string)", "function owner() view returns (address)", "function isAdmin(address) view returns (bool)", "function balanceOf(address) view returns (uint256)", "function ownerOf(uint256) view returns (address)", "function tokenURI(uint256) view returns (string)", "function approve(address,uint256)", "function setApprovalForAll(address,bool)", "function isApprovedForAll(address,address) view returns (bool)", "function registerExtension(address,string)", "function getExtensions() view returns (address[])", "function tokenExtension(uint256) view returns (address)", "function mintBase(address,string) returns (uint256)", "function mintBaseBatch(address,string[]) returns (uint256[])", "function royaltyInfo(uint256,uint256) view returns (address,uint256)", ]; /** A Manifold creator contract, through the minimal ABI above. */ export function creatorAt(ethers, address, runner) { return new ethers.Contract(address, CREATOR_ABI, runner ?? ethers.provider); } /** * Deploy a Manifold ERC721Creator from frozen bytecode — for chains where * COLORGASM does not exist (a local chain, Sepolia). See the _comment in * fixtures/ERC721Creator.json for why this is a fixture rather than a source * dependency. Nothing about mainnet touches this. */ export async function deployCreator(ethers, name, symbol, signer) { const fixture = JSON.parse( readFileSync(join(ROOT, "fixtures", "ERC721Creator.json"), "utf8"), ); const from = signer ?? (await ethers.getSigners())[0]; const factory = new ethers.ContractFactory(fixture.abi, fixture.bytecode, from); const creator = await factory.deploy(name, symbol); await creator.waitForDeployment(); return creator; } // ---------------------------------------------------------------- network -- /** * Connect to the network chosen with --network, and report who we are. */ export async function connect() { const conn = await network.getOrCreate(); const { ethers } = conn; const [signer] = await ethers.getSigners(); const { chainId, name } = await ethers.provider.getNetwork(); const net = conn.networkName ?? name; if (signer === undefined) { throw new Error( `No signer for network "${net}". Set PRIVATE_KEY in .env.`, ); } const balance = await ethers.provider.getBalance(signer.address); console.log( `network ${net} (chainId ${chainId})\n` + `signer ${signer.address} — ${ethers.formatEther(balance)} ETH\n`, ); return { ...conn, ethers, signer, chainId, net }; } /** * Refuse to send transactions anywhere but an in-process chain. * * Measuring scripts deploy contracts and send real transactions to do their * measuring. Pointed at a live network by an innocent-looking `--network * mainnet`, they would spend real ETH — so they call this first and stop. */ export async function assertLocalChain(ethers, what) { const { chainId } = await ethers.provider.getNetwork(); if (chainId !== 31337n) { throw new Error( `${what} measures by actually deploying and sending transactions, so it must\n` + ` run on a local in-process chain — but this is chainId ${chainId}.\n` + ` Drop the --network flag (or use --network probe). A live network is only\n` + ` ever read from, for its current gas price.`, ); } } // ------------------------------------------------------------ deployments -- const file = (net) => join(ROOT, "deployments", `${net}.json`); /** Read the recorded deployment for a network ({} if there is none yet). */ export function readDeployment(net) { const p = file(net); return existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : {}; } /** Merge values into a network's deployment record. */ export function saveDeployment(net, values) { const p = file(net); mkdirSync(dirname(p), { recursive: true }); const next = { ...readDeployment(net), ...values, updatedAt: new Date().toISOString() }; writeFileSync(p, JSON.stringify(next, null, 2) + "\n"); console.log(`\nrecorded in deployments/${net}.json`); return next; } /** * Resolve a contract address: an explicit env var wins, then the deployment * record, then (for the creator on mainnet) the known CLGSM address. */ export function addressFor(kind, net, { required = true } = {}) { const fromEnv = process.env[kind.toUpperCase()]; if (fromEnv) return fromEnv; const recorded = readDeployment(net)[kind]; if (recorded) return recorded; if (kind === "creator" && net === "mainnet") return CLGSM_MAINNET; if (required) { throw new Error( `No ${kind} address for "${net}". Set ${kind.toUpperCase()}=0x… or run the ` + `deploy script for this network first.`, ); } return undefined; } // ------------------------------------------------------------------ bytes -- /** Split a byte array into SSTORE2-sized hex chunks. */ export function toChunks(bytes, size = CHUNK) { const out = []; for (let o = 0; o < bytes.length; o += size) { out.push("0x" + Buffer.from(bytes.subarray(o, o + size)).toString("hex")); } return out; } /** Chunks for storing an image RAW — 3-aligned so base64 stays chunk-local. */ export function rawChunks(image) { return toChunks(image, CHUNK); } /** * Chunks for storing an image PRE-ENCODED: base64 the whole image once, * offchain, and store the characters. Rendering is then a straight copy with no * encoding onchain at all — the cheapest tokenURI there is, at the cost of * storing 4 bytes for every 3. */ export function base64Chunks(image) { const encoded = Buffer.from(Buffer.from(image).toString("base64"), "ascii"); return toChunks(encoded, CHUNK); } /** Storage key for an artwork: a readable bytes32 name, not a hash. */ export function artworkKey(id, prefix = process.env.KEY_PREFIX ?? "COLORGASM_") { const name = `${prefix}${id}`; const bytes = Buffer.from(name, "utf8"); if (bytes.length > 31) throw new Error(`storage key "${name}" is too long for bytes32`); return "0x" + Buffer.concat([bytes, Buffer.alloc(32 - bytes.length)]).toString("hex"); } /** The name behind a bytes32 key, for logging. */ export function keyName(key) { return Buffer.from(key.replace(/^0x/, ""), "hex").toString("utf8").replace(/\0+$/, ""); } /** * Read a blob back byte for byte, one eth_getCode per SSTORE2 pointer. Never * pulls a whole image through a single eth_call, so it works for large images * and against rate-limited providers. */ export async function readBlobBytes(blobs, provider, key) { const pointers = await blobs.chunksOf(key); const parts = []; for (const p of pointers) { const code = await provider.getCode(p); parts.push(Buffer.from(code.slice(4), "hex")); // strip "0x" + STOP byte } return Buffer.concat(parts); } /** * Decode an onchain tokenURI. Handles both forms the renderers produce: * `data:application/json;base64,…` and `data:application/json;utf8,…` * (ColorgasmRendererV2 with setBase64Json(false), which is far cheaper to * render — see scripts/render-ceiling.js). */ export function decodeTokenURI(uri) { const b64 = "data:application/json;base64,"; const utf8 = "data:application/json;utf8,"; const plain = "data:application/json,"; let body; if (uri.startsWith(b64)) body = Buffer.from(uri.slice(b64.length), "base64").toString("utf8"); else if (uri.startsWith(utf8)) body = decodeURIComponent(uri.slice(utf8.length)); else if (uri.startsWith(plain)) body = decodeURIComponent(uri.slice(plain.length)); else throw new Error(`not an onchain data URI: ${uri.slice(0, 80)}…`); const json = JSON.parse(body); const m = /^data:([^;]+);base64,(.*)$/s.exec(json.image ?? ""); if (m === null) throw new Error("token JSON has no image data URI"); return { json, mime: m[1], imageBytes: Buffer.from(m[2], "base64") }; } // ------------------------------------------------------------ local files -- /** * Where the artwork lives. Defaults match the CLGSMS folder layout: * ../onchain/COLORGASM_#.avif (encode_onchain.py output) * ./meta/.json (scripts/fetch-metadata.js output) */ export const AVIF_DIR = process.env.AVIF_DIR ?? join(ROOT, "..", "onchain"); export const META_DIR = process.env.META_DIR ?? join(ROOT, "meta"); export const AVIF_NAME = process.env.AVIF_NAME ?? "COLORGASM_#{id}.avif"; export function avifPath(id) { return join(AVIF_DIR, AVIF_NAME.replace("{id}", String(id))); } export function metaPath(id) { return join(META_DIR, `${id}.json`); } /** * Which tokens to act on. `IDS=57,3,12` wins over `FROM_ID`/`TO_ID` and is * taken in the order given — every step works one token at a time, in any * order, so a single piece can be done end to end before the rest. */ export function tokenRange(defaultFrom = 1, defaultTo = 100) { if (process.env.IDS) { const ids = process.env.IDS.split(",").map((s) => Number(s.trim())); if (ids.some((n) => !Number.isInteger(n) || n < 1)) { throw new Error(`bad IDS=${process.env.IDS}`); } return ids; } const from = Number(process.env.FROM_ID ?? defaultFrom); const to = Number(process.env.TO_ID ?? defaultTo); if (!Number.isInteger(from) || !Number.isInteger(to) || from > to) { throw new Error(`bad token range FROM_ID=${from} TO_ID=${to}`); } return Array.from({ length: to - from + 1 }, (_, i) => from + i); } /** * Storage shape: RAW image bytes (the default) or their base64. * * This has to agree everywhere, and the renderer's `preEncoded` is IMMUTABLE — * uploading one shape and rendering the other produces a token that decodes to * garbage, permanently if the keys are sealed. So scripts do not just read an * env var: they check it against the renderer that is actually deployed, and * refuse on a mismatch. */ export function storageShape() { return process.env.PRE_ENCODED === "1"; } /** Throw unless the deployed renderer agrees with the shape we are about to use. */ export async function assertShapeMatchesRenderer(ethers, ext, preEncoded) { const rendererAddress = await ext.renderer(); if (rendererAddress === "0x0000000000000000000000000000000000000000") { throw new Error("the extension has no renderer yet — run scripts/deploy.js first"); } const renderer = await ethers.getContractAt("ColorgasmRendererV3", rendererAddress); const onchain = await renderer.preEncoded(); if (onchain !== preEncoded) { throw new Error( `storage shape mismatch: this run would use ${preEncoded ? "PRE-ENCODED base64" : "RAW bytes"}, ` + `but the renderer at ${rendererAddress} expects ${onchain ? "PRE-ENCODED base64" : "RAW bytes"}.\n` + ` preEncoded is immutable — either set PRE_ENCODED=${onchain ? "1" : "0"} for every script, ` + `or deploy a renderer for the other shape and setRenderer() to it.`, ); } return renderer; } /** Fail loudly rather than half-running a script. */ export function main(fn) { fn().catch((e) => { console.error(`\n✗ ${e.message}`); process.exitCode = 1; }); }