/** * 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 * * Expects, per token: * ../onchain/COLORGASM_#.avif the compressed image (AVIF_DIR/AVIF_NAME) * * Each artwork goes to its own storage key (`COLORGASM_` 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 * transaction per token. * * Env: * STORAGE default: deployments/.json * EXTENSION default: deployments/.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"; import { CHUNK, addressFor, artworkKey, assertShapeMatchesRenderer, avifPath, base64Chunks, connect, keyName, main, rawChunks, readBlobBytes, storageShape, tokenRange, } from "./_lib.js"; main(async () => { 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); console.log( `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`, ); let gasTotal = 0n; for (const id of ids) { const imgPath = avifPath(id); if (!existsSync(imgPath)) { console.log(`#${id}: missing ${imgPath} — skipped`); continue; } 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([ blobs.isSealed(key), readBlobBytes(blobs, ethers.provider, key), ]); if (stored.equals(want)) { console.log( `#${id}: already stored — ${want.length} B under "${keyName(key)}"` + `${sealed ? " (sealed)" : ""}`, ); if (seal && !sealed && !dryRun) { gasTotal += await send(blobs.seal(key), " seal"); } continue; } if (sealed) { throw new Error( `#${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)) { throw new Error( `#${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); console.log( `#${id}: ${image.length} B image -> ${want.length} B stored in ${chunks.length} chunk(s)` + (done > 0 ? `, resuming from ${done}` : ""), ); if (dryRun) continue; 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)) { throw new Error( `#${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" : ""}`); } if (gasTotal > 0n) { const fee = (await ethers.provider.getFeeData()).gasPrice ?? 0n; console.log( `\ntotal ${gasTotal} gas` + (fee > 0n ? ` ≈ ${ethers.formatEther(gasTotal * fee)} ETH at ${ethers.formatUnits(fee, "gwei")} gwei` : ""), ); } console.log( `\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) { let seen = 0; for (let i = 0; i < chunks.length; i++) { seen += (chunks[i].length - 2) / 2; if (seen === storedLength) return i + 1; if (seen > storedLength) { throw new Error( `stored length ${storedLength} falls inside chunk ${i + 1} — the blob was written ` + `with a different chunk size (this build uses ${CHUNK})`, ); } } return chunks.length; } 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})`); return receipt.gasUsed; } /** Hold off while the base fee is above MAX_GWEI — this is a long, costly job. */ async function waitForGas(ethers, maxGwei) { for (;;) { const { gasPrice } = await ethers.provider.getFeeData(); if (gasPrice === null || gasPrice <= ethers.parseUnits(String(maxGwei), "gwei")) return; console.log( ` gas ${ethers.formatUnits(gasPrice, "gwei")} gwei > MAX_GWEI=${maxGwei} — waiting 60s`, ); await new Promise((r) => setTimeout(r, 60_000)); } }