a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/scripts/estimate.js
JavaScript · 224 lines · 9 KBRAWDOWNLOAD
/**
* 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;
}