* PHASE 1a — upload artworks to the blob storage, one chunk per transaction. * IDS=57 npx hardhat run scripts/upload.js --network mainnet * FROM_ID=1 TO_ID=100 npx hardhat run scripts/upload.js --network mainnet * ../onchain/COLORGASM_#<id>.avif the compressed image (AVIF_DIR/AVIF_NAME) * Each artwork goes to its own storage key (`COLORGASM_<id>` by default) and is * sealed when complete — after that its bytes can never change. The key is the * pointer; `scripts/point.js` maps it to a tokenId afterwards, in one small * STORAGE default: deployments/<network>.json * EXTENSION default: deployments/<network>.json (read to check the shape) * IDS comma-separated tokenIds, in any order (wins over FROM_ID/TO_ID) * FROM_ID/TO_ID token range, default 1..100 * PRE_ENCODED=1 store the base64 of the image rather than the image itself. * The default is RAW — cheapest to store, and the renderer * base64s it at read time. This MUST match how the renderer * was deployed; the script checks and refuses on a mismatch. * MAX_GWEI refuse to send above this base fee (default 10 on mainnet) * NO_SEAL=1 leave keys unsealed (so you can still fix a bad upload) * DRY_RUN=1 report what it would cost, send nothing * Resumable: a key whose stored bytes already match the file is skipped, and a * key that stopped part-way continues from the chunk it reached. import { existsSync, readFileSync } from "node:fs"; assertShapeMatchesRenderer, const { ethers, net } = await connect(); const storageAddress = addressFor("storage", net); const blobs = await ethers.getContractAt("ColorgasmStorage", storageAddress); const ids = tokenRange(); const dryRun = process.env.DRY_RUN === "1"; const preEncoded = storageShape(); const seal = process.env.NO_SEAL !== "1"; const maxGwei = Number(process.env.MAX_GWEI ?? (net === "mainnet" ? 10 : 1000)); // The renderer's preEncoded is immutable. Uploading the wrong shape would // render every token as garbage, permanently once the keys are sealed, so // check against what is actually deployed before spending anything. const ext = await ethers.getContractAt( "ColorgasmRedeemExtension", addressFor("extension", net), await assertShapeMatchesRenderer(ethers, ext, preEncoded); `storage ${storageAddress}\n` + `tokens ${ids.length === 1 ? `#${ids[0]}` : `#${ids.join(", #")}`}\n` + `shape ${preEncoded ? "pre-encoded base64" : "raw bytes"} — matches the deployed renderer` + `${dryRun ? " (DRY RUN)" : ""}\n`, const imgPath = avifPath(id); if (!existsSync(imgPath)) { console.log(`#${id}: missing ${imgPath} — skipped`); const image = readFileSync(imgPath); const chunks = preEncoded ? base64Chunks(image) : rawChunks(image); const want = Buffer.concat(chunks.map((c) => Buffer.from(c.slice(2), "hex"))); const key = artworkKey(id); const [sealed, stored] = await Promise.all([ readBlobBytes(blobs, ethers.provider, key), if (stored.equals(want)) { `#${id}: already stored — ${want.length} B under "${keyName(key)}"` + `${sealed ? " (sealed)" : ""}`, if (seal && !sealed && !dryRun) { gasTotal += await send(blobs.seal(key), " seal"); `#${id}: "${keyName(key)}" is sealed and does not match ${imgPath}. ` + `Sealed keys are permanent — upload this artwork under a different key.`, if (stored.length > 0 && !want.subarray(0, stored.length).equals(stored)) { `#${id}: "${keyName(key)}" holds ${stored.length} B that do not match the ` + `start of ${imgPath}. Use a different key rather than appending to this one.`, const done = stored.length === 0 ? 0 : chunksCovering(stored.length, chunks); `#${id}: ${image.length} B image -> ${want.length} B stored in ${chunks.length} chunk(s)` + (done > 0 ? `, resuming from ${done}` : ""), for (let i = done; i < chunks.length; i++) { await waitForGas(ethers, maxGwei); gasTotal += await send(blobs.append(key, chunks[i]), ` chunk ${i + 1}/${chunks.length}`); const after = await readBlobBytes(blobs, ethers.provider, key); if (!after.equals(want)) { `#${id}: stored bytes do not match ${imgPath} (${after.length} vs ${want.length}) — stopping`, if (seal) gasTotal += await send(blobs.seal(key), " seal"); console.log(` verified ${after.length} B under "${keyName(key)}"${seal ? ", sealed" : ""}`); const fee = (await ethers.provider.getFeeData()).gasPrice ?? 0n; `\ntotal ${gasTotal} gas` + ? ` ≈ ${ethers.formatEther(gasTotal * fee)} ETH at ${ethers.formatUnits(fee, "gwei")} gwei` `\nnext: point the tokens at their artworks\n` + ` IDS=${ids.join(",")} npx hardhat run scripts/point.js --network ${net}`, /** How many whole chunks `storedLength` bytes covers. */ function chunksCovering(storedLength, chunks) { for (let i = 0; i < chunks.length; i++) { seen += (chunks[i].length - 2) / 2; if (seen === storedLength) return i + 1; if (seen > storedLength) { `stored length ${storedLength} falls inside chunk ${i + 1} — the blob was written ` + `with a different chunk size (this build uses ${CHUNK})`, async function send(pending, label) { const tx = await pending; console.log(`${label} ${tx.hash}`); const receipt = await tx.wait(); if (receipt.status !== 1) throw new Error(`${label} reverted (${tx.hash})`); /** Hold off while the base fee is above MAX_GWEI — this is a long, costly job. */ async function waitForGas(ethers, maxGwei) { const { gasPrice } = await ethers.provider.getFeeData(); if (gasPrice === null || gasPrice <= ethers.parseUnits(String(maxGwei), "gwei")) return; ` gas ${ethers.formatUnits(gasPrice, "gwei")} gwei > MAX_GWEI=${maxGwei} — waiting 60s`, await new Promise((r) => setTimeout(r, 60_000));