// SPDX-License-Identifier: MIT import {Ownable} from "solady/src/auth/Ownable.sol"; import {SSTORE2} from "solady/src/utils/SSTORE2.sol"; ICreatorExtensionTokenURI, } from "./interfaces/IColorgasm.sol"; /// @title COLORGASM onchain redeem extension /// @author Felix (COLORGASM) — extension for Manifold creator contract CLGSM /// @notice A Manifold ERC721 Creator Core extension that migrates the original /// Arweave-hosted edition to fully onchain tokens, in two phases: /// PHASE 1 — the artist uploads each artwork to a blob storage contract /// offchain-script-side, one chunk per transaction, and then points a /// tokenId at it here with `setArtwork(originalTokenId, key, metadata)` /// — a single small transaction per token. The image bytes never pass /// through this contract, so nothing here is bounded by the /// 16,777,216-gas transaction cap. /// PHASE 2 — once a tokenId has both an artwork pointer and a metadata /// fragment, burn/redeem becomes available for it: the holder calls /// `burnRedeem(tokenId)`, which burns the original, maps the stored /// artwork to the newly minted tokenId, and mints the new token to /// the holder — one transaction, no data arguments. /// tokenURI for redeemed tokens is delegated by the creator core to /// this extension, which forwards to a swappable renderer. /// - deploy storage, extension and renderer; `setBlobStorage` and /// `setRenderer` on the extension /// - creator admin calls `registerExtension(address(this), "")` on CLGSM /// - artist uploads artworks, then points tokenIds at them; /// `isRedeemable(id)` turns true per token /// - holder calls `approve(this, tokenId)` (or `setApprovalForAll`) on /// CLGSM, then `burnRedeem(tokenId)` contract ColorgasmRedeemExtension is Ownable, ICreatorExtensionTokenURI, IColorgasmArtworkSource { // ---------------------------------------------------------------- errors -- error RendererIsLocked(); // ---------------------------------------------------------------- events -- event ArtworkSet(uint256 indexed originalTokenId, bytes32 key, uint256 metadataBytes); event Redeemed(address indexed redeemer, uint256 indexed burnedTokenId, uint256 indexed newTokenId); event BlobStorageUpdated(address blobStorage); event RendererUpdated(address renderer); event RendererLockedForever(); event StorageLockedForever(); // -------------------------------------------------------------- immutable -- /// @notice The Manifold creator contract (CLGSM). address public immutable creator; /// @notice First tokenId of the original edition (1 for CLGSM). uint256 public immutable firstOriginalId; /// @notice Number of pieces in the edition (100 for CLGSM). uint256 public immutable editionSize; // ---------------------------------------------------------------- storage -- /// @notice Where the artwork bytes live. Swappable until `lockStorage()`. address public blobStorage; /// @notice Metadata renderer; swappable until `lockRenderer()`. /// @notice True once the renderer can no longer be changed. bool public rendererLocked; /// @notice True once artwork pointers and metadata can no longer be changed. bool public storageLocked; /// @inheritdoc IColorgasmArtworkSource mapping(uint256 => bytes32) public artworkKeyOf; /// @dev originalTokenId => SSTORE2 pointer to the metadata JSON fragment. mapping(uint256 => address) private _metadata; /// @inheritdoc IColorgasmArtworkSource mapping(uint256 => uint256) public originalOf; /// @notice originalTokenId => tokenId of the redeemed (onchain) token, 0 if not redeemed. mapping(uint256 => uint256) public redeemedTokenOf; /// @notice Number of originals redeemed so far. uint256 public redeemedCount; // ------------------------------------------------------------ constructor -- constructor(address creator_, uint256 firstOriginalId_, uint256 editionSize_) { _initializeOwner(msg.sender); firstOriginalId = firstOriginalId_; editionSize = editionSize_; // -------------------------------------------- PHASE 1: artwork pointers --- /// @notice Point one original token at its stored artwork, and set its /// metadata fragment. Artist only. /// @dev The artwork itself is uploaded separately, straight to the blob /// storage contract — this only records where it is, so the call is /// a few tens of thousands of gas however large the artwork. /// @param originalTokenId original tokenId (1..editionSize) this data belongs to. /// @param key storage key holding the artwork bytes. Pass /// bytes32(0) to keep the current pointer. /// @param metadata the token's original JSON minus the image field, /// WITHOUT surrounding braces, e.g. /// `"name":"COLORGASM #001","description":"..."`. /// Pass "" to keep the current fragment. function setArtwork(uint256 originalTokenId, bytes32 key, bytes calldata metadata) if (storageLocked) revert StorageIsLocked(); _requireInEdition(originalTokenId); if (key == bytes32(0) && metadata.length == 0) revert NothingToStore(); if (key != bytes32(0)) artworkKeyOf[originalTokenId] = key; if (metadata.length != 0) _metadata[originalTokenId] = SSTORE2.write(metadata); emit ArtworkSet(originalTokenId, artworkKeyOf[originalTokenId], metadata.length); /// @notice Point many tokens at their artworks at once. Metadata fragments /// are set separately, since they are the only bulky part. function setArtworkBatch(uint256[] calldata originalTokenIds, bytes32[] calldata keys) if (storageLocked) revert StorageIsLocked(); uint256 n = originalTokenIds.length; if (n != keys.length) revert LengthMismatch(); for (uint256 i; i < n; ++i) { _requireInEdition(originalTokenIds[i]); if (keys[i] == bytes32(0)) revert NothingToStore(); artworkKeyOf[originalTokenIds[i]] = keys[i]; emit ArtworkSet(originalTokenIds[i], keys[i], 0); /// @notice Point the blob storage at a (new) contract. Locked by `lockStorage`. function setBlobStorage(address blobStorage_) external onlyOwner { if (storageLocked) revert StorageIsLocked(); blobStorage = blobStorage_; emit BlobStorageUpdated(blobStorage_); /// @notice Irreversibly freeze every artwork pointer, the metadata and the /// storage address. Call once everything is verified onchain — the /// "the art can never change" switch collectors can verify. Seal the /// keys on the storage contract too, or this only freezes the /// pointers and not what they point at. function lockStorage() external onlyOwner { emit StorageLockedForever(); // -------------------------------------------- PHASE 2: burn / redeem ------ /// @notice Burn an original token and receive its fully onchain replacement. /// Available only once the artist has pointed that token at its /// artwork and metadata (`isRedeemable(originalTokenId) == true`). /// @dev Caller must own the original and must have approved this contract /// on the creator (approve or setApprovalForAll) so it can burn. /// @param originalTokenId tokenId of the original (Arweave-era) token to burn. /// @return newTokenId tokenId of the newly minted onchain token. function burnRedeem(uint256 originalTokenId) external returns (uint256 newTokenId) { _checkRedeemable(originalTokenId); if (IERC721Lite(creator).ownerOf(originalTokenId) != msg.sender) revert NotTokenOwner(); IERC721CreatorCore(creator).burn(originalTokenId); newTokenId = IERC721CreatorCore(creator).mintExtension(msg.sender); originalOf[newTokenId] = originalTokenId; redeemedTokenOf[originalTokenId] = newTokenId; emit Redeemed(msg.sender, originalTokenId, newTokenId); /// @notice Batch variant — redeem several originals held by the same wallet function burnRedeemBatch(uint256[] calldata originalTokenIds) returns (uint256[] memory newTokenIds) uint256 n = originalTokenIds.length; if (n == 0 || n > editionSize) revert OutOfRange(); for (uint256 i; i < n; ++i) { uint256 id = originalTokenIds[i]; if (IERC721Lite(creator).ownerOf(id) != msg.sender) revert NotTokenOwner(); IERC721CreatorCore(creator).burn(id); newTokenIds = IERC721CreatorCore(creator).mintExtensionBatch(msg.sender, uint16(n)); for (uint256 i; i < n; ++i) { originalOf[newTokenIds[i]] = originalTokenIds[i]; redeemedTokenOf[originalTokenIds[i]] = newTokenIds[i]; emit Redeemed(msg.sender, originalTokenIds[i], newTokenIds[i]); // -------------------------------------------------------------- renderer -- /// @notice Point tokenURI construction at a (new) renderer contract. function setRenderer(address renderer_) external onlyOwner { if (rendererLocked) revert RendererIsLocked(); emit RendererUpdated(renderer_); /// @notice Irreversibly freeze the renderer. function lockRenderer() external onlyOwner { emit RendererLockedForever(); // ----------------------------------------------------------------- views -- /// @notice True once the artist has set both an artwork pointer and a /// metadata fragment for an original token that is not yet redeemed. function isRedeemable(uint256 originalTokenId) public view returns (bool) { return artworkKeyOf[originalTokenId] != bytes32(0) && _metadata[originalTokenId] != address(0) && redeemedTokenOf[originalTokenId] == 0 && renderer != address(0) && blobStorage != address(0); /// @inheritdoc ICreatorExtensionTokenURI /// @dev Called by the creator core for every token this extension minted. function tokenURI(address creator_, uint256 tokenId) external view override returns (string memory) { if (creator_ != creator) revert InvalidCreator(); uint256 originalTokenId = originalOf[tokenId]; if (originalTokenId == 0) revert UnknownToken(); if (r == address(0)) revert RendererNotSet(); return IColorgasmRenderer(r).tokenURI(creator_, tokenId, originalTokenId); /// @inheritdoc IColorgasmArtworkSource function metadataOf(uint256 originalTokenId) public view override returns (bytes memory) { address ptr = _metadata[originalTokenId]; if (ptr == address(0)) return ""; return SSTORE2.read(ptr); /// @dev ERC165 — must report ICreatorExtensionTokenURI for the creator core /// to delegate tokenURI here. function supportsInterface(bytes4 interfaceId) external pure returns (bool) { return interfaceId == type(ICreatorExtensionTokenURI).interfaceId || interfaceId == 0x01ffc9a7; // -------------------------------------------------------------- internal -- function _checkRedeemable(uint256 originalTokenId) internal view { _requireInEdition(originalTokenId); if (!isRedeemable(originalTokenId)) revert NotRedeemable(); function _requireInEdition(uint256 originalTokenId) internal view { if (originalTokenId < firstOriginalId || originalTokenId >= firstOriginalId + editionSize) {