a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/test/local.test.js
JavaScript · 364 lines · 13 KBRAWDOWNLOAD
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"),
);
});
});
});