a manifold extension to port fully onchain an existing collection minted with offchain content
clgsms/contracts/ColorgasmRedeemExtension.sol
Solidity · 295 lines · 13 KBRAWDOWNLOAD
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Ownable} from "solady/src/auth/Ownable.sol";
import {SSTORE2} from "solady/src/utils/SSTORE2.sol";
import {
ICreatorExtensionTokenURI,
IERC721CreatorCore,
IERC721Lite,
IColorgasmArtworkSource,
IColorgasmRenderer
} 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.
///
/// @dev Wiring:
/// - 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 OutOfRange();
error NotTokenOwner();
error NotRedeemable();
error StorageIsLocked();
error RendererIsLocked();
error RendererNotSet();
error InvalidCreator();
error UnknownToken();
error NothingToStore();
error LengthMismatch();
// ---------------------------------------------------------------- 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()`.
address public renderer;
/// @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);
creator = creator_;
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)
public
onlyOwner
{
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)
external
onlyOwner
{
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 {
storageLocked = true;
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;
unchecked {
++redeemedCount;
}
emit Redeemed(msg.sender, originalTokenId, newTokenId);
}
/// @notice Batch variant — redeem several originals held by the same wallet
/// in one transaction.
function burnRedeemBatch(uint256[] calldata originalTokenIds)
external
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];
_checkRedeemable(id);
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]);
}
unchecked {
redeemedCount += n;
}
}
// -------------------------------------------------------------- renderer --
/// @notice Point tokenURI construction at a (new) renderer contract.
function setRenderer(address renderer_) external onlyOwner {
if (rendererLocked) revert RendererIsLocked();
renderer = renderer_;
emit RendererUpdated(renderer_);
}
/// @notice Irreversibly freeze the renderer.
function lockRenderer() external onlyOwner {
rendererLocked = true;
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();
address r = renderer;
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) {
revert OutOfRange();
}
}
}