a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/scripts/upload.js
JavaScript · 189 lines · 7 KBRAWDOWNLOAD
/**
* 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_#<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
* transaction per token.
*
* Env:
* 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";
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));
}
}