import assert from "node:assert/strict"; import { describe, it, before, beforeEach } from "node:test"; import { network } from "hardhat"; import { CHUNK, artworkKey, base64Chunks, creatorAt, decodeTokenURI, deployCreator, deployStack, fakeImage, fragment, sameBytes, upload, } from "./helpers.js"; /** * Full flow on a plain in-process chain, against a real Manifold ERC721Creator * deployed in the test itself. No RPC, no keys — this is the suite to run on * every change. test/fork.test.js repeats it against the real CLGSM contract on * a mainnet fork. */ describe("COLORGASM redeem — local chain", () => { const ARWEAVE = "https://arweave.net/2ydrm9ZHH7qxYrybOI4XeY9elm2JjpwSVXjcBhaZL6k/"; const EDITION = 100; let ethers; let artist, holder, stranger; let creator, blobs, ext, renderer; before(async () => { ({ ethers } = await network.create()); }); beforeEach(async () => { [artist, holder, stranger] = await ethers.getSigners(); // A Manifold creator contract standing in for CLGSM, with originals 1..5 // minted to the holder carrying their Arweave URIs, as on mainnet. const deployed = await deployCreator(ethers, "COLORGASM", "CLGSM", artist); creator = creatorAt(ethers, await deployed.getAddress(), artist); for (let id = 1; id <= 5; id++) { await (await creator.mintBase(holder.address, `${ARWEAVE}${id}`)).wait(); } ({ blobs, ext, renderer } = await deployStack(ethers, await creator.getAddress(), { editionSize: EDITION, })); await (await creator.registerExtension(await ext.getAddress(), "")).wait(); }); /** PHASE 1 as the artist runs it: upload the artwork, then point a token at it. */ async function store(id, image, meta = fragment(id)) { const { key } = await upload(blobs, id, image); await (await ext.setArtwork(id, key, ethers.toUtf8Bytes(meta))).wait(); return key; } /** PHASE 2: approve the extension and redeem, returning the new tokenId. */ async function redeem(id, as = holder) { await (await creator.connect(as).approve(await ext.getAddress(), id)).wait(); const newId = await ext.connect(as).burnRedeem.staticCall(id); await (await ext.connect(as).burnRedeem(id)).wait(); return newId; } describe("wiring", () => { it("reports ICreatorExtensionTokenURI so the creator delegates tokenURI", async () => { const id = ethers.id("tokenURI(address,uint256)").slice(0, 10); assert.equal(await ext.supportsInterface(id), true); assert.equal(await ext.supportsInterface("0x01ffc9a7"), true); assert.equal(await ext.supportsInterface("0xdeadbeef"), false); }); it("knows its creator, edition bounds, storage and renderer", async () => { assert.equal(await ext.creator(), await creator.getAddress()); assert.equal(await ext.firstOriginalId(), 1n); assert.equal(await ext.editionSize(), BigInt(EDITION)); assert.equal(await ext.blobStorage(), await blobs.getAddress()); assert.equal(await ext.renderer(), await renderer.getAddress()); }); it("leaves the original tokens on their Arweave URIs", async () => { assert.equal(await creator.tokenURI(1), `${ARWEAVE}1`); }); }); describe("storage", () => { it("stores a blob one chunk per transaction and reads it back exactly", async () => { const image = fakeImage(CHUNK * 2 + 4_321, 3); const key = artworkKey(1); const chunks = base64Chunks(image); for (const chunk of chunks) await (await blobs.append(key, chunk)).wait(); assert.equal(await blobs.chunkCount(key), BigInt(chunks.length)); assert.equal(await blobs.sizeOf(key), BigInt(Buffer.from(image).toString("base64").length)); assert.equal( Buffer.from((await blobs.read(key)).slice(2), "hex").toString("ascii"), Buffer.from(image).toString("base64"), "the stored characters must be exactly the base64 of the image", ); }); it("gives a key to its first writer and keeps everyone else out", async () => { const key = artworkKey(1); await (await blobs.append(key, "0x0102030405060708090a0b0c")).wait(); assert.equal(await blobs.ownerOf(key), artist.address); await assert.rejects( blobs.connect(stranger).append(key, "0x0102030405060708090a0b0c"), (e) => e.message.includes("NotOwner"), ); }); it("freezes a key forever once sealed", async () => { const key = artworkKey(1); await (await blobs.append(key, "0x0102030405060708090a0b0c")).wait(); await (await blobs.seal(key)).wait(); assert.equal(await blobs.isSealed(key), true); await assert.rejects( blobs.append(key, "0x0102030405060708090a0b0c"), (e) => e.message.includes("KeySealed"), ); }); it("refuses an empty chunk, an oversized one, and sealing nothing", async () => { const key = artworkKey(1); await assert.rejects(blobs.append(key, "0x"), (e) => e.message.includes("EmptyChunk")); await assert.rejects( blobs.append(key, "0x" + "ab".repeat(CHUNK + 1)), (e) => e.message.includes("ChunkTooLarge"), ); await assert.rejects(blobs.seal(key), (e) => e.message.includes("NotOwner")); }); }); describe("phase 1 — pointers", () => { it("is not redeemable before anything is pointed at", async () => { assert.equal(await ext.isRedeemable(1), false); await assert.rejects( ext.connect(holder).burnRedeem(1), (e) => e.message.includes("NotRedeemable"), ); }); it("is not redeemable with only one half set", async () => { await (await ext.setArtwork(1, artworkKey(1), "0x")).wait(); assert.equal(await ext.isRedeemable(1), false); await (await ext.setArtwork(2, ethers.ZeroHash, ethers.toUtf8Bytes(fragment(2)))).wait(); assert.equal(await ext.isRedeemable(2), false); }); it("costs the same tiny transaction however large the artwork", async () => { // The whole point of the pointer indirection: the image never passes // through this contract, so PHASE 1 here is never near any gas cap. const receipt = await ( await ext.setArtwork(1, artworkKey(1), ethers.toUtf8Bytes(fragment(1))) ).wait(); console.log(` setArtwork: ${receipt.gasUsed} gas`); assert.ok(receipt.gasUsed < 200_000n, `setArtwork should be small (${receipt.gasUsed})`); }); it("rejects tokenIds outside the edition", async () => { for (const id of [0, EDITION + 1, 1000]) { await assert.rejects( ext.setArtwork(id, artworkKey(id), ethers.toUtf8Bytes("x")), (e) => e.message.includes("OutOfRange"), `id ${id} should be out of range`, ); } }); it("rejects a call that would set nothing", async () => { await assert.rejects( ext.setArtwork(1, ethers.ZeroHash, "0x"), (e) => e.message.includes("NothingToStore"), ); }); it("only lets the artist set pointers", async () => { await assert.rejects( ext.connect(stranger).setArtwork(1, artworkKey(1), ethers.toUtf8Bytes("x")), (e) => e.message.includes("Unauthorized"), ); }); it("points many tokens at their artworks in one transaction", async () => { const ids = [1, 2, 3, 4, 5]; const keys = ids.map((id) => artworkKey(id)); const receipt = await (await ext.setArtworkBatch(ids, keys)).wait(); console.log(` setArtworkBatch(${ids.length}): ${receipt.gasUsed} gas`); for (const id of ids) assert.equal(await ext.artworkKeyOf(id), artworkKey(id)); await assert.rejects( ext.setArtworkBatch([1, 2], [artworkKey(1)]), (e) => e.message.includes("LengthMismatch"), ); }); it("replaces the pointer and keeps the metadata, and vice versa", async () => { const meta = fragment(1); await (await ext.setArtwork(1, artworkKey(1), ethers.toUtf8Bytes(meta))).wait(); await (await ext.setArtwork(1, artworkKey(42), "0x")).wait(); assert.equal(await ext.artworkKeyOf(1), artworkKey(42)); assert.equal(ethers.toUtf8String(await ext.metadataOf(1)), meta); const meta2 = fragment(1, { description: "corrected" }); await (await ext.setArtwork(1, ethers.ZeroHash, ethers.toUtf8Bytes(meta2))).wait(); assert.equal(ethers.toUtf8String(await ext.metadataOf(1)), meta2); assert.equal(await ext.artworkKeyOf(1), artworkKey(42)); }); it("freezes pointers forever once locked", async () => { await store(1, fakeImage(500)); await (await ext.lockStorage()).wait(); assert.equal(await ext.storageLocked(), true); await assert.rejects( ext.setArtwork(2, artworkKey(2), ethers.toUtf8Bytes("x")), (e) => e.message.includes("StorageIsLocked"), ); await assert.rejects( ext.setBlobStorage(stranger.address), (e) => e.message.includes("StorageIsLocked"), ); assert.equal(await ext.isRedeemable(1), true); }); }); describe("phase 2 — redeem", () => { const IMAGE = fakeImage(CHUNK * 2 + 1_234, 99); beforeEach(async () => { await store(1, IMAGE); }); it("burns the original and mints an onchain replacement", async () => { const newId = await redeem(1); await assert.rejects(creator.ownerOf(1)); assert.equal(await creator.ownerOf(newId), holder.address); assert.equal(await ext.originalOf(newId), 1n); assert.equal(await ext.redeemedTokenOf(1), newId); assert.equal(await ext.redeemedCount(), 1n); }); it("serves the whole token from chain state", async () => { const newId = await redeem(1); const { json, mime, imageBytes } = decodeTokenURI(await creator.tokenURI(newId)); assert.equal(mime, "image/avif"); sameBytes(imageBytes, IMAGE, "image bytes must survive the round trip exactly"); assert.equal(json.name, "COLORGASM #001"); assert.equal(json.created_by, "FelixFelixFelix"); assert.equal(json.attributes[0].value, "COLORGASM"); assert.equal(json.image_url, undefined); }); it("refuses a holder who does not own the token", async () => { await assert.rejects( ext.connect(stranger).burnRedeem(1), (e) => e.message.includes("NotTokenOwner"), ); }); it("refuses without approval", async () => { await assert.rejects(ext.connect(holder).burnRedeem(1)); }); it("cannot be redeemed twice", async () => { await redeem(1); assert.equal(await ext.isRedeemable(1), false); await assert.rejects( ext.connect(holder).burnRedeem(1), (e) => e.message.includes("NotRedeemable"), ); }); it("redeems a batch in one transaction", async () => { for (const id of [2, 3]) await store(id, fakeImage(600 + id, id)); await ( await creator.connect(holder).setApprovalForAll(await ext.getAddress(), true) ).wait(); const newIds = await ext.connect(holder).burnRedeemBatch.staticCall([1, 2, 3]); await (await ext.connect(holder).burnRedeemBatch([1, 2, 3])).wait(); assert.equal(newIds.length, 3); assert.equal(await ext.redeemedCount(), 3n); for (const [i, original] of [1, 2, 3].entries()) { assert.equal(await ext.originalOf(newIds[i]), BigInt(original)); assert.equal(await creator.ownerOf(newIds[i]), holder.address); await assert.rejects(creator.ownerOf(original)); } const { json } = decodeTokenURI(await creator.tokenURI(newIds[2])); assert.equal(json.name, "COLORGASM #003"); }); it("rejects an empty batch", async () => { await assert.rejects( ext.connect(holder).burnRedeemBatch([]), (e) => e.message.includes("OutOfRange"), ); }); it("leaves unredeemed originals on Arweave", async () => { await redeem(1); assert.equal(await creator.tokenURI(2), `${ARWEAVE}2`); }); it("still renders after both storage locks", async () => { const newId = await redeem(1); assert.equal(await blobs.isSealed(artworkKey(1)), true, "upload seals the key"); await (await ext.lockStorage()).wait(); const { imageBytes } = decodeTokenURI(await creator.tokenURI(newId)); sameBytes(imageBytes, IMAGE); }); }); describe("renderer", () => { beforeEach(async () => { await store(1, fakeImage(800, 5)); }); it("can be swapped until it is locked", async () => { const next = await ethers.deployContract("ColorgasmRendererV3", [ await ext.getAddress(), await blobs.getAddress(), true, ]); await next.waitForDeployment(); await (await ext.setRenderer(await next.getAddress())).wait(); assert.equal(await ext.renderer(), await next.getAddress()); await (await ext.lockRenderer()).wait(); await assert.rejects( ext.setRenderer(await renderer.getAddress()), (e) => e.message.includes("RendererIsLocked"), ); }); it("follows a MIME change", async () => { const newId = await redeem(1); await (await renderer.setImageMime("image/png")).wait(); const { mime } = decodeTokenURI(await creator.tokenURI(newId)); assert.equal(mime, "image/png"); }); it("rejects tokenURI for an unknown token and a foreign creator", async () => { await assert.rejects( ext.tokenURI(await creator.getAddress(), 999), (e) => e.message.includes("UnknownToken"), ); await assert.rejects( ext.tokenURI(stranger.address, 1), (e) => e.message.includes("InvalidCreator"), ); }); }); });