/** * What will PHASE 1 cost? Per file, and for the whole collection. * * npx hardhat run scripts/estimate.js * B64_DIR=../b64 GWEI=0.5,1,3,10 npx hardhat run scripts/estimate.js * npx hardhat run scripts/estimate.js --network mainnet # also uses the live base fee * * Reads every file in the folder, splits it the way scripts/upload.js would, * and prices the `append` transactions against a real ColorgasmStorage. * * The measuring is ALWAYS done on a local in-process chain, whatever --network * says: it deploys a storage contract and sends real chunk transactions to see * what they cost, and doing that on mainnet would spend real ETH. A live * --network is only ever read from, for its current gas price. * * Env: * B64_DIR folder of files to price (default ../b64) * EXT file extension to read (default .txt) * GWEI comma-separated gas prices to cost out (default 0.5,1,3,10) * KEEP_PREFIX=1 store the `data:...;base64,` prefix as part of the blob * instead of stripping it (see the warning it prints) * VALIDATE=0 skip the end-to-end check of the model * * The per-chunk cost is measured, not modelled from constants: the script * deploys the contract, appends chunks of known sizes, and solves for the fixed * and per-byte parts. It then uploads the largest file for real and reports how * far the model was off — usually zero. */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { network } from "hardhat"; import { CHUNK, ROOT, assertLocalChain, main } from "./_lib.js"; const DATA_URI = /^data:[^,]*;base64,/; main(async () => { const dir = process.env.B64_DIR ?? join(ROOT, "..", "b64"); const ext = process.env.EXT ?? ".txt"; const keepPrefix = process.env.KEEP_PREFIX === "1"; const prices = (process.env.GWEI ?? "0.5,1,3,10").split(",").map(Number); if (!existsSync(dir)) throw new Error(`no such folder: ${dir} (set B64_DIR)`); const files = readdirSync(dir) .filter((f) => f.toLowerCase().endsWith(ext.toLowerCase())) .sort(byNaturalOrder); if (files.length === 0) throw new Error(`no ${ext} files in ${dir}`); // Measuring happens here, on a throwaway in-process chain — never on // whatever --network was passed. const { ethers } = await network.create({ network: "probe" }); await assertLocalChain(ethers, "scripts/estimate.js"); // ...and the live network, if one was named, is only read for its gas price. let live = null; try { const conn = await network.getOrCreate(); const { chainId } = await conn.ethers.provider.getNetwork(); if (chainId !== 31337n) { const fee = (await conn.ethers.provider.getFeeData()).gasPrice; if (fee !== null && fee > 0n) { live = { name: conn.networkName, gwei: Number(conn.ethers.formatUnits(fee, "gwei")) }; } } } catch { // no live network configured, or unreachable — pricing still works } // --- read the files and work out how upload.js would split them --------- let prefixSeen = null; const blobs = files.map((name) => { let body = readFileSync(join(dir, name)); const head = body.subarray(0, 64).toString("latin1"); const m = DATA_URI.exec(head); if (m !== null) { prefixSeen = m[0]; if (!keepPrefix) body = body.subarray(m[0].length); } // A trailing newline would corrupt the data URI; upload.js would store it. const trailing = body.length - body.toString("latin1").replace(/[\r\n]+$/, "").length; if (trailing > 0) body = body.subarray(0, body.length - trailing); const sizes = []; for (let o = 0; o < body.length; o += CHUNK) { sizes.push(Math.min(CHUNK, body.length - o)); } return { name, bytes: body.length, sizes, trailing, body }; }); if (prefixSeen !== null) { console.log( keepPrefix ? `! storing the "${prefixSeen}" prefix as part of each blob.\n` + ` ColorgasmRendererV3 writes its own prefix, so this only works with a\n` + ` renderer changed to emit the stored bytes verbatim.\n` : `stripping the "${prefixSeen}" prefix from each file — the renderer writes\n` + `its own. Set KEEP_PREFIX=1 to price it as stored.\n`, ); } const withNewlines = blobs.filter((b) => b.trailing > 0).length; if (withNewlines > 0) { console.log(`! trimmed a trailing newline from ${withNewlines} file(s) before pricing\n`); } // --- measure what an append actually costs ------------------------------ const storage = await ethers.deployContract("ColorgasmStorage"); await storage.waitForDeployment(); const key = (s) => ethers.encodeBytes32String(s); const filler = (n) => "0x" + Buffer.alloc(n, 0x41).toString("hex"); // 'A', as base64 text is // Two sizes on a fresh key give the first-chunk cost and the per-byte slope; // a second append on the same key gives the cheaper subsequent-chunk cost. const gFirstBig = await gasOf(storage.append(key("cal-a"), filler(CHUNK))); const gNextBig = await gasOf(storage.append(key("cal-a"), filler(CHUNK))); const gFirstSmall = await gasOf(storage.append(key("cal-b"), filler(3000))); const gNextSmall = await gasOf(storage.append(key("cal-b"), filler(3000))); const gSeal = await gasOf(storage.seal(key("cal-a"))); const perByte = Number(gNextBig - gNextSmall) / (CHUNK - 3000); const fixedNext = Number(gNextBig) - perByte * CHUNK; const fixedFirst = Number(gFirstBig) - perByte * CHUNK; const sealGas = Number(gSeal); const cost = (b) => Math.round( fixedFirst + perByte * b.sizes[0] + b.sizes.slice(1).reduce((a, s) => a + fixedNext + perByte * s, 0) + sealGas, ); console.log( `measured on a local ColorgasmStorage:\n` + ` ${perByte.toFixed(2)} gas per stored byte ` + `(200 code deposit + 16 calldata, plus overhead)\n` + ` first chunk of a key ${fmt(fixedFirst)} fixed ` + `later chunks ${fmt(fixedNext)} fixed seal ${fmt(sealGas)}\n` + ` chunk size ${CHUNK} B -> ${fmt(perByte * CHUNK + fixedNext)} per full chunk\n`, ); // --- per file ----------------------------------------------------------- const w = Math.max(12, ...blobs.map((b) => b.name.length)); console.log( ` ${"file".padEnd(w)} ${"stored".padStart(9)} chunks ${"gas".padStart(13)} ` + prices.map((g) => `${g} gwei`.padStart(10)).join(" "), ); let totalGas = 0; let totalBytes = 0; for (const b of blobs) { const g = cost(b); totalGas += g; totalBytes += b.bytes; console.log( ` ${b.name.padEnd(w)} ${b.bytes.toLocaleString("en-US").padStart(9)} ` + `${String(b.sizes.length).padStart(6)} ${g.toLocaleString("en-US").padStart(13)} ` + prices.map((p) => eth(g, p).padStart(10)).join(" "), ); } console.log( ` ${"".padEnd(w, "-")} ${"-".repeat(9)} ------ ${"-".repeat(13)} ` + prices.map(() => "-".repeat(10)).join(" "), ); console.log( ` ${`${blobs.length} files`.padEnd(w)} ${totalBytes.toLocaleString("en-US").padStart(9)} ` + `${String(blobs.reduce((a, b) => a + b.sizes.length, 0)).padStart(6)} ` + `${totalGas.toLocaleString("en-US").padStart(13)} ` + prices.map((p) => eth(totalGas, p).padStart(10)).join(" "), ); // Pointing tokens at artworks is the other half of PHASE 1. const pointing = 142_000 * blobs.length; console.log( `\n + ${blobs.length} x setArtwork (~142k each) ${pointing.toLocaleString("en-US")} gas ` + prices.map((p) => `${eth(pointing, p)} @ ${p} gwei`).join(" "), ); if (live !== null) { console.log( `\n ${live.name} right now: ${live.gwei.toFixed(2)} gwei -> ` + `${eth(totalGas + pointing, live.gwei)} ETH for the whole collection`, ); } // --- prove the model ---------------------------------------------------- if (process.env.VALIDATE !== "0") { const biggest = blobs.reduce((a, b) => (b.bytes > a.bytes ? b : a)); console.log(`\nvalidating against a real upload of ${biggest.name} (${biggest.sizes.length} chunks)...`); const k = ethers.encodeBytes32String("validate"); let actual = 0n; for (let o = 0, i = 0; o < biggest.body.length; o += CHUNK, i++) { const chunk = "0x" + biggest.body.subarray(o, o + CHUNK).toString("hex"); actual += await gasOf(storage.append(k, chunk)); } actual += await gasOf(storage.seal(k)); const predicted = cost(biggest); const drift = Number(actual) - predicted; console.log( ` predicted ${predicted.toLocaleString("en-US")} gas, actual ${actual.toLocaleString("en-US")} ` + `(${drift >= 0 ? "+" : ""}${drift} gas, ${((Math.abs(drift) / predicted) * 100).toFixed(3)}%)`, ); if (Math.abs(drift) / predicted > 0.01) { console.log(" ! over 1% out — the numbers above are indicative, not exact"); } } }); async function gasOf(pending) { return (await (await pending).wait()).gasUsed; } const fmt = (g) => Math.round(g).toLocaleString("en-US"); const eth = (gas, gwei) => (gas * gwei * 1e-9).toFixed(4); /** COLORGASM_2 before COLORGASM_10. */ function byNaturalOrder(a, b) { const na = Number(a.match(/(\d+)/)?.[1] ?? 0); const nb = Number(b.match(/(\d+)/)?.[1] ?? 0); return na === nb ? a.localeCompare(b) : na - nb; }