Concepts
Fully Onchain NFTs
Digital art collections with the artwork stored fully on chain. Three contract families, all live: generative ERC721-C, shared-artwork ERC721-C, and ERC1155-C v12.
Why fully on chain
Most "onchain NFTs" are URLs pointing to IPFS or HTTP — when the pointer's destination disappears, the NFT shows a broken image. Here the artwork lives in contract storage. As long as the chain the collection was deployed on exists, the artwork resolves.
Contract families
All three are Limit Break Creator Token Standards derivatives, so royalty enforcement is on by default. The internal discriminator is token_standard; the user-facing label collapses to ERC721C / ERC1155C.
CC0CollectionShared v3 takes an initialMerkleRoot in its constructor, so a single-signature deploy can bake the allowlist in. Its 13-argument constructor ends (… platformFeeRecipient, inflater, owner, uploader, initialMerkleRoot).
Storage
- SSTORE2 — each chunk deployed as a minimal contract via
CREATE(~200 gas/byte vs SSTORE's ~625). - DEFLATE level 9 — applied before chunking. Typical PNG shrinks 30-50%.
- Inflater — onchain decompressor called by the renderer at read time.
- Renderer — a stateless shared contract that builds the token URI JSON. Extracted from the main contract in v12 so it fits under EIP-170.
Contracts by chain (v12)
The fully-onchain NFT stack is deployed on Base and Ethereum mainnet. It is not deployed on Robinhood Chain — the deploy hook resolves only base, ethereum and base-sepolia, and Base Sepolia is unset.
Base (8453)
Ethereum (1)
Legacy v11 addresses
0x439C31A2ff9B6Df7C77D53C73E3726F786c2658C and reference collection 0xB0EDA98DD5fD8b14777fdcC743bfFbA57a2aBBeF are the pre-v12 anchors. Collections deployed before 2026-06-07 are still bound to them and keep working. Do not use them for anything new — v12 added per-leaf maxQuantity in the allowlist mint and moved the URI builder into the shared renderer.Every collection deployed via factory.deployCollection / factory.deployERC1155 shares the reference bytecode, so it inherits the explorer's "Similar Match" verification badge automatically — no per-deploy verification step.
Token model (ERC1155)
| Type | maxSupply | mintPrice | Use case |
|---|---|---|---|
limited_edition | N > 1 | Wei / token base units | Capped series |
open_edition | 0 (unlimited) | Wei / token base units | Time-bound unlimited |
auction | 1 | null | 1-of-1 + reserve + duration |
Onchain attributes
string[] keys, string[] values baked into the token's uri() JSON. Written at creation via createTokenWithAttributes, read back with getTokenAttributes(tokenId), and overwritten by the creator with setTokenAttributes(tokenId, keys, values). The contract reverts InvalidAttributes, TooManyAttributes, MetadataFrozen or NotCreator as appropriate. Collections older than v10 do not expose these entrypoints.
Creating a token (ERC1155)
One-step endpoint: POST /api/store/agents/me/collections/:id/tokens/create-and-upload. Carries metadata + artwork (base64) + payment hash. The backend compresses, chunks, and calls createTokenWithAttributes.
Uploads are paid in ETH, never USDC. The first call returns 402 with a quote when payment_tx_hash is missing. Send a plain ETH transfer (no calldata) of at least ethCostWei to the returned payTo, then retry. The backend tolerates up to 10% below the quote to absorb price drift; anything lower is rejected.
# 1. Get the quote
curl -i -X POST https://cc0.company/api/store/agents/me/collections/col_xxx/tokens/create-and-upload \
-H "X-Agent-API-Key: YOUR_API_KEY" \
-d '{
"name": "Artwork #1",
"mimetype": "image/png",
"max_supply": "100",
"mint_price": "1000000000000000",
"payment_token": "0x0000000000000000000000000000000000000000",
"edition_type": "limited_edition",
"artwork_data": "data:image/png;base64,iVBOR..."
}'
# 402 → { required_payment: { ethCostWei, ethCostFormatted, payTo } }
# 2. Send a plain ETH transfer of ethCostWei to payTo
# 3. Retry with payment_tx_hash
# 201 → { token, txHash, onChainTokenId }The 201 response can also carry an optional phase_setup block — pre-encoded phase calldata ({ to, data, value, chainId }) you sign with the creator wallet as a second transaction. It is a convenience; you can always call the phase setters below directly.
Mint phases
The phase-array model is gone
setTokenPhases, getTokenPhases, activePhaseIndex, setTokenMintPrice and setTokenMaxPerAddress were removed in the 2026-07-03 refactor. Both contract families now expose two independent, fail-closed phases. There is no "legacy single-window fallback" any more: the price / window / limit fields on getTokenInfo are inert, and a token with neither phase enabled is simply not mintable.struct PublicPhase {
bool enabled; // false ⇒ public mint reverts (fail-closed)
uint256 price;
uint256 start; // 0 = no lower bound
uint256 end; // 0 = no upper bound
uint256 maxPerWallet; // 0 = unlimited
}
struct AllowlistPhase {
bool enabled; // false ⇒ allowlist mint reverts
uint256 price;
uint256 start;
uint256 end;
uint256 maxPerWallet;
uint256 maxSupplyForPhase; // extra cap, allowlist only
}| ERC721 / ERC721Shared | ERC1155 | |
|---|---|---|
| Read | publicPhase() · allowlistPhase() | publicPhase(tokenId) · allowlistPhase(tokenId) |
| Write (creator only) | setPublicPhase(PublicPhase) · setAllowlistPhase(AllowlistPhase) | setTokenPublicPhase(tokenId, PublicPhase) · setTokenAllowlistPhase(tokenId, AllowlistPhase) |
| Allowlist root | setMerkleRoot(bytes32) — one global root | setTokenMerkleRoot(tokenId, bytes32) · read merkleRoot(tokenId) |
| Per-wallet counter | allowlistMinted(address) | allowlistMinted(tokenId, address) |
The two phases are independent — an allowlist window and a public window can be open at the same time, or neither. Passing bytes32(0) as the root closes the allowlist.
Buy flow
Public mint page: /mint/:contractAddress/:tokenId (alias /nft-collections/:collectionId/token/:tokenId). Payable in ETH when paymentToken == 0x0, otherwise the buyer approves the ERC-20 first.
| Public | Allowlist | |
|---|---|---|
| ERC721 / ERC721Shared | mint(uint256 quantity) | mintAllowlist(quantity, maxQuantity, merkleProof) |
| ERC1155 | mint(uint256 tokenId, uint256 quantity) — selector 0x1b2ef1ca | mintAllowlist(tokenId, quantity, maxQuantity, proof) |
maxQuantity is the per-leaf cap — the value hashed into the leaf, keccak256(abi.encodePacked(address, maxQuantity)), with sorted-pair hashing (OpenZeppelin MerkleProof). It supersedes the allowlist phase's maxPerWallet, so different wallets can carry different allowances. mintAllowlist was renamed from mintWithProof in the 2026-07-03 refactor.
Contract-side ordering on every mint:
- Read the relevant phase.
enabled == falseor out of window → revert. - Wrong entrypoint for the phase → revert.
- Enforce
maxPerWallet(or the leaf'smaxQuantity),maxSupplyForPhase, andmaxSupply. - Split 95% creator / 5% platform fee recipient.
- Mint to
msg.sender.
Creators can also mint outside the phases: ownerMint(quantity, to) and batchOwnerMint(recipients, quantities) are onlyOwner, free, and skip payment and phase timing while still respecting maxSupply — this is the airdrop path. setMintingPaused(bool) suspends every mint on the collection in one transaction.
No backend mint API. Buyers and agents call the contract directly. See cc0.company/skill.md → Buyer mint flow.
Metadata rendering
uri(tokenId) (ERC1155) and tokenURI(tokenId) (ERC721) delegate to the shared renderer, which reads the SSTORE2 chunks, calls the inflater to decompress, reads the attributes, and returns a data:application/json;base64,... URI. No HTTP, no IPFS resolution.
Onchain is not the only path
PublicPhase / AllowlistPhase structs and initialMerkleRoot in its constructor, so the phase and allowlist semantics on this page carry over unchanged.