PERMANENT COLLECTIONProtocol Reference

PunkVault

0x3614692b8C8B22890D66a5DfcBc6F6eAdEdE036f · view on evm.now · deployed at block 25270161

PunkVault is the immutable terminal custodian for vaulted Punks. When a 72-hour return auction ends with no bid, the ReturnAuctionModule transfers the Punk to the vault's address on the 2017 CryptoPunks market and calls receivePunk. From that point the Punk is permanently held: the vault's bytecode contains no path to any market write function (transferPunk, offerPunkForSale, acceptBidForPunk, or any other), so no key, role, or future contract can move a vaulted Punk.

The vault is also a solmate ERC721 contract (name Title to PERMANENT COLLECTION Vault, symbol PERMANENTCOLLECTION) issuing the protocol's 112 named tokens: the 111 Proofs (token ids 0..110, one per first-vaulting of a previously uncollected trait, where tokenId == traitId) and the one-of-one Vault Title (token id 111). Token ids 112 and above are unreachable from any code path. The Proofs and the Title are ordinary transferable ERC721 tokens; the no-exit guarantee applies to the Punks, not to these role-of-record tokens.

The vault has no admin functions. Its two wiring slots (titleAuction, rendererRegistry) are deployer-only one-shot setters that lock after first use, and its ERC-173 owner() slot is a marketplace-editor handle with zero on-chain authority and a one-way renounceOwnership().

Concepts

Punks are not ERC721 tokens

CryptoPunks predate ERC721. A vaulted Punk is held as a plain ownership entry at the market's punkIndexToAddress(punkId) slot, with the vault's address as the owner. Nothing about a vaulted Punk appears in this contract's ERC721 accounting: balanceOf, ownerOf, totalSupply, and the transfer surface cover only the Proofs and the Title. To enumerate vaulted Punks, index the PunkLocked event log or read isLocked(punkId) / lockedPunkCount().

Two minters, disjoint id ranges

The two token classes are issued by two distinct immutable minters whose id ranges cannot overlap:

  • returnAuctionModule (set at construction, never rotatable) is the only caller of receivePunk and mintProofs. mintProofs can only produce token ids 0..110 (the id is derived from the trait id, which is checked against PROOF_COUNT), at most once per trait
  • titleAuction (wired once via setTitleAuction) is the only caller of mintToAuction, which mints exactly token id 111, at most once

Neither minter has a path into the other's range, and no path mints an id at or above 112.

Proof metadata is frozen at mint

Each Proof records a ProofMeta struct at mint time: the punkId whose vaulting brought the trait into the collection, the traitId (equal to the token id), the 1-based sequence in collection order, and mintedAtBlock. All four fields are written once and never change, even when the Proof is transferred. The immutable contribution record and the current holder are separate questions: read proofMeta(tokenId) for the former, ownerOf(tokenId) for the latter. sequence diverges from tokenId because traits are vaulted in an arbitrary order, not in trait-id order.

Proofs are minted with _mint, not _safeMint. There is no onERC721Received callback, so a contract recipient without receiver support still gets its Proof, and no recipient can grief a Punk's settlement by reverting in a callback.

tokenURI dispatch through the RendererRegistry

tokenURI(id) and contractURI() hold no metadata themselves. After local existence checks, both delegate to the wired RendererRegistry, which forwards to the live renderer implementation. The renderer dispatches on the id: ids 0..110 resolve to the Proof renderer, id 111 to the Title JSON. contractURI() resolves to the same content as the Title's tokenURI(111). Queries for unminted tokens revert (UnknownTokenId for an unminted Proof id, TitleNotMinted for id 111 before the Title exists); there is no preview envelope.

The owner() slot: marketplace editor only, one-way ratchet

The vault exposes ERC-173 owner() solely so OpenSea, Blur, and Magic Eden recognize a wallet as the collection-page editor (banner, profile image, description override, social links). The slot is initialized to the deployer EOA at construction and is only ever settable to address(0) via renounceOwnership(). There is intentionally no transferOwnership: once renounced, no key compromise can re-acquire the editor surface. The owner has zero on-chain authority. It gates no vault function, cannot touch the Punks, and does not control the metadata content (which comes from the RendererRegistry).

Metadata refresh signals

The vault emits ERC-7572 ContractURIUpdated() on the Title mint and on every Proof mint, since both change the collection-progress fields marketplaces display, and EIP-4906 MetadataUpdate(tokenId) when a token's rendered attributes may have changed (each mint, plus MetadataUpdate(111) on every receivePunk once the Title exists, because the Title's attributes include the vaulted-Punk count). supportsInterface advertises EIP-4906 (0x49064906) so indexers know to listen.

Live reads

bash
# Proofs issued so far (0..111)
cast call 0x3614692b8C8B22890D66a5DfcBc6F6eAdEdE036f "totalProofsMinted()(uint256)" \
  --rpc-url https://ethereum-rpc.publicnode.com

# Frozen metadata for Proof token id 20 (trait 20):
# (punkId, traitId, sequence, mintedAtBlock)
cast call 0x3614692b8C8B22890D66a5DfcBc6F6eAdEdE036f \
  "proofMeta(uint256)(uint16,uint8,uint16,uint64)" 20 \
  --rpc-url https://ethereum-rpc.publicnode.com

proofMeta returns all zeros for an unminted id; check isProofMinted(traitId) first to distinguish "not yet minted" from a Proof genuinely minted with zero-valued fields (only Punk #0 as the acquired Punk could make punkId zero, and mintedAtBlock is never zero for a real mint).

Write functions

receivePunk

solidity
function receivePunk(uint16 punkId) external

Access: returnAuctionModule-only (NotReturnAuction for any other caller)

Registers a Punk as permanently vaulted. The caller must have already executed transferPunk(vault, punkId) on the CryptoPunks market; receivePunk verifies current market ownership via punkIndexToAddress and reverts NotOwnedByVault(punkId) if the vault isn't the holder. Reverts AlreadyLocked(punkId) if the Punk was already registered. On success it sets isLocked[punkId], increments lockedPunkCount, emits PunkLocked(punkId), and, once the Title has been minted, emits MetadataUpdate(111) so indexers refresh the Title's attributes (which include the vaulted-Punk count).

There is no reverse operation. No function on this contract can release a Punk, and the bytecode contains no CryptoPunks market write selector.

mintProofs

solidity
function mintProofs(uint16 punkId, uint8 targetTraitId, address recipient, uint256 acquisitionId, uint16 sequence) external

Access: returnAuctionModule-only (NotReturnAuction for any other caller)

Mints the Proof for targetTraitId as token id uint256(targetTraitId) (ids 0..110) to recipient, the originalSeller recorded on the acquisition whose vault-settle produced this Proof. Called by ReturnAuctionModule.settle atomically with the vaulting when the settle is the first vaulting of a previously uncollected trait; a revert here rolls back the entire settle.

Checks, in order: caller gate, InvalidTraitId if targetTraitId >= 111, InvalidRecipient if recipient == address(0), ProofAlreadyMinted if the trait's bit is already set in proofsMintedMask. On success it sets the bit, increments proofsMintedCount, writes the frozen proofMeta record (punkId, traitId, sequence, block.number), and mints with _mint (no onERC721Received callback, so a contract recipient can neither be stranded nor grief the settle). Emits ProofMinted, the ERC721 Transfer from address(0), MetadataUpdate(tokenId), and ContractURIUpdated().

acquisitionId (the 0-based index into PermanentCollection's acquisition log) and sequence (the 1-based collection order at mint time) are recorded verbatim; the vault does not re-derive them.

mintToAuction

solidity
function mintToAuction() external

Access: titleAuction-only (NotTitleAuction for any other caller)

Mints the Vault Title (token id 111) to the titleAuction contract itself, which escrows it for its auction. One-shot: reverts TitleAlreadyMinted on any second call. Emits TitleMinted(titleAuction), the ERC721 Transfer, MetadataUpdate(111), and ContractURIUpdated(). Reachable permissionlessly through the title auction's own idempotent mintTitle() entry point; the vault only checks that the immediate caller is the wired auction.

setTitleAuction

solidity
function setTitleAuction(address _titleAuction) external

Access: deployer one-shot (NotDeployer for others; TitleAuctionAlreadySet

once wired)

Binds the titleAuction address exactly once. Reverts ZeroAddress for address(0). After this call mintToAuction is callable only by the bound address and the slot can never be changed. Emits TitleAuctionSet. Part of the deploy broadcast; already executed on mainnet.

setRendererRegistry

solidity
function setRendererRegistry(address _rendererRegistry) external

Access: deployer one-shot (NotDeployer for others;

RendererRegistryAlreadySet once wired)

Binds the RendererRegistry address exactly once. Reverts ZeroAddress for address(0). After this call tokenURI and contractURI delegate to the registry and the slot can never be changed (the registry itself fronts a swappable renderer implementation until its own freeze). Emits RendererRegistrySet. Part of the deploy broadcast; already executed on mainnet.

renounceOwnership

solidity
function renounceOwnership() external

Access: owner-only (NotOwner for any other caller)

Permanently sets the ERC-173 owner() slot to address(0) and emits OwnershipTransferred(previousOwner, address(0)). One-way: there is no transferOwnership, so no address can ever hold the slot again, and marketplaces will refuse all future collection-page edits. The slot carries no on-chain authority, so renouncing changes nothing about the vault's behavior.

approve

solidity
function approve(address spender, uint256 id) external

Access: permissionless (token owner or an isApprovedForAll operator; solmate

reverts NOT_AUTHORIZED otherwise)

Standard ERC721 single-token approval for a Proof or the Title. No protocol-specific behavior.

setApprovalForAll

solidity
function setApprovalForAll(address operator, bool approved) external

Access: permissionless (sets the caller's own operator flag)

Standard ERC721 operator approval covering all of the caller's PunkVault tokens. No protocol-specific behavior.

transferFrom

solidity
function transferFrom(address from, address to, uint256 id) external

Access: permissionless (owner, approved spender, or operator of id)

Standard solmate ERC721 transfer of a Proof or the Title. Uses string reverts from the solmate base for the standard failure modes (WRONG_FROM, NOT_AUTHORIZED, INVALID_RECIPIENT for a zero to). Transferring a Proof moves ownership only; its proofMeta record is frozen and travels with the token id, not the holder. Transferring the Title moves titular ownership of the vault (a display-side role; it grants no claim on the Punks and no authority over any contract).

safeTransferFrom

solidity
function safeTransferFrom(address from, address to, uint256 id) external

Access: permissionless (owner, approved spender, or operator of id)

transferFrom plus the ERC721 receiver check: if to is a contract it must return the onERC721Received selector (solmate reverts UNSAFE_RECIPIENT otherwise). No protocol-specific behavior.

safeTransferFrom

solidity
function safeTransferFrom(address from, address to, uint256 id, bytes data) external

Access: permissionless (owner, approved spender, or operator of id)

Same as the 3-argument form, forwarding data to the recipient's onERC721Received. No protocol-specific behavior.

Read functions

balanceOf

solidity
function balanceOf(address owner) external view returns (uint256)

Standard ERC721 balance over the vault's own tokens (Proofs + Title), not Punks. Reverts (solmate ZERO_ADDRESS) for the zero address.

contractURI

solidity
function contractURI() external view returns (string)

ERC-7572 collection-level metadata JSON (data URI). Delegates to RendererRegistry.contractURI(address(this)); resolves to the same content as the Title's tokenURI(111). Reverts RendererRegistryNotSet if the registry was never wired.

getApproved

solidity
function getApproved(uint256) external view returns (address)

Standard ERC721 per-token approval getter. Raw mapping read: returns address(0) for unminted ids rather than reverting.

isApprovedForAll

solidity
function isApprovedForAll(address, address) external view returns (bool)

Standard ERC721 operator-approval getter. Raw mapping read.

isLocked

solidity
function isLocked(uint16) external view returns (bool)

Per-Punk vaulted flag, keyed by Punk id. Once true, stays true forever. This is the Punk-side record (distinct from the ERC721 accounting, which never includes Punks).

isProofMinted

solidity
function isProofMinted(uint8 traitId) external view returns (bool)

True iff the Proof for traitId has been minted. Returns false (rather than reverting) for traitId >= 111. Cheaper than ownerOf(traitId), which reverts pre-mint. Use this to guard proofMeta reads.

lockedPunkCount

solidity
function lockedPunkCount() external view returns (uint256)

Count of permanently vaulted Punks. Monotonic. The per-Punk history is the PunkLocked event log; there is no on-chain array accessor for the full list.

MAX_PROOF_TOKEN_ID

solidity
function MAX_PROOF_TOKEN_ID() external view returns (uint256)

Constant 110. Highest valid Proof token id; Proofs occupy ids 0..MAX_PROOF_TOKEN_ID inclusive with tokenId == traitId.

name

solidity
function name() external view returns (string)

ERC721 name: Title to PERMANENT COLLECTION Vault.

owner

solidity
function owner() external view returns (address)

ERC-173 owner. The deployer EOA until renounceOwnership() is called, then address(0) forever. Read by marketplaces to decide which wallet can edit the collection page; carries no on-chain authority (see Concepts).

ownerOf

solidity
function ownerOf(uint256 id) external view returns (address owner)

Standard ERC721 owner lookup for Proof ids 0..110 and the Title (111). Reverts (solmate NOT_MINTED) for unminted or out-of-range ids. Never answers for Punks; use punksMarket.punkIndexToAddress(punkId) for Punk custody.

PROOF_COUNT

solidity
function PROOF_COUNT() external view returns (uint8)

Constant 111. Number of distinct Proofs, one per trait in the sealed PunksData taxonomy.

proofMeta

solidity
function proofMeta(uint256) external view returns (uint16 punkId, uint8 traitId, uint16 sequence, uint64 mintedAtBlock)

Frozen per-Proof metadata, keyed by Proof token id: (punkId, traitId, sequence, mintedAtBlock). Written once at mint and never mutated, even across transfers. Returns all zeros for an unminted id; check isProofMinted first. See the Concepts section for a cast example.

proofsMintedCount

solidity
function proofsMintedCount() external view returns (uint16)

Number of Proofs minted so far (0..111). Equals popcount(proofsMintedMask), tracked explicitly so the renderer composes its "N of 111" progress inscription in one read.

proofsMintedMask

solidity
function proofsMintedMask() external view returns (uint256)

Bitmap of minted Proofs: bit traitId is set iff that trait's Proof exists. Single read for computing full issued-so-far state.

punksMarket

solidity
function punksMarket() external view returns (ICryptoPunksMarket)

The 2017 CryptoPunks market contract (0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB). Immutable. Vaulted Punks live at this contract's punkIndexToAddress slot with the vault as owner.

rendererRegistry

solidity
function rendererRegistry() external view returns (address)

The wired RendererRegistry address that tokenURI and contractURI delegate to. address(0) only before the one-shot setRendererRegistry wiring (already executed on mainnet).

returnAuctionModule

solidity
function returnAuctionModule() external view returns (address)

The only address that may call receivePunk and mintProofs. Immutable, set at construction (the deploy precomputes the module's CREATE address so the two contracts can reference each other).

supportsInterface

solidity
function supportsInterface(bytes4 interfaceId) external view returns (bool)

ERC165. Advertises ERC721 (0x80ac58cd), ERC721Metadata (0x5b5e139f), ERC165 itself (0x01ffc9a7), and EIP-4906 (0x49064906) so marketplaces know to listen for MetadataUpdate.

symbol

solidity
function symbol() external view returns (string)

ERC721 symbol: PERMANENTCOLLECTION.

TITLE_TOKEN_ID

solidity
function TITLE_TOKEN_ID() external view returns (uint256)

Constant 111. Token id of the one-of-one Vault Title, sitting just past the Proof range.

titleAuction

solidity
function titleAuction() external view returns (address)

The only address that may call mintToAuction. Wired once via the deployer one-shot setTitleAuction.

titleMinted

solidity
function titleMinted() external view returns (bool)

True iff the Vault Title (token id 111) has been minted.

titleOwner

solidity
function titleOwner() external view returns (address)

Current holder of the Vault Title. Returns address(0) before the Title is minted; otherwise equivalent to ownerOf(111) (initially the title auction contract, which escrows it until its auction settles).

tokenURI

solidity
function tokenURI(uint256 id) external view returns (string)

Token metadata JSON (data URI) for a minted Proof (ids 0..110) or the minted Title (id 111). Reverts UnknownTokenId(id) for an unminted Proof id or any id at or above 112, TitleNotMinted for id 111 before the Title exists, and RendererRegistryNotSet if the registry was never wired. Resolution delegates to RendererRegistry.tokenURI(id), which forwards to the live renderer; the renderer dispatches on the id (Proof renderer for 0..110, Title JSON for 111).

totalProofsMinted

solidity
function totalProofsMinted() external view returns (uint256)

Number of Proofs minted so far, as uint256 (same value as proofsMintedCount). Caps at 111.

totalSupply

solidity
function totalSupply() external view returns (uint256)

Total PunkVault-issued ERC721 tokens: minted Proofs plus the Title if minted (maximum 112). For marketplaces and indexers without ERC721Enumerable. Does not count vaulted Punks; that's lockedPunkCount.

Events

Approval

solidity
event Approval(address indexed owner, address indexed spender, uint256 indexed id)

Standard ERC721 single-token approval event for Proofs and the Title.

ApprovalForAll

solidity
event ApprovalForAll(address indexed owner, address indexed operator, bool approved)

Standard ERC721 operator-approval event.

ContractURIUpdated

solidity
event ContractURIUpdated()

ERC-7572 collection-metadata refresh hint. Emitted on the Title mint and on every Proof mint, since each changes the collection-progress fields (totalSupply, the renderer's "N of 111" inscription, the Title JSON's progress attributes). Marketplaces that honor ERC-7572 re-fetch contractURI() on this event instead of waiting on poll cadence.

MetadataUpdate

solidity
event MetadataUpdate(uint256 _tokenId)

EIP-4906 per-token metadata refresh hint. Emitted with the minted token's id on each mint, and with id 111 on every receivePunk once the Title exists (the Title's rendered attributes include the vaulted-Punk count). Indexers should re-fetch tokenURI(_tokenId).

OwnershipTransferred

solidity
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)

ERC-173 ownership transition. Exactly two emissions are possible over the contract's lifetime: (address(0) → deployer) at construction and (deployer → address(0)) at renounceOwnership().

ProofMinted

solidity
event ProofMinted(uint256 indexed tokenId, uint8 indexed traitId, uint16 indexed punkId, address recipient, uint256 acquisitionId, uint16 sequence, uint256 mintedAtBlock)

Emitted once per Proof, alongside the ERC721 Transfer from address(0). Indexers should read: tokenId (= traitId, 0..110), punkId (the Punk whose vaulting brought the trait in), recipient (the originalSeller who gave up the Punk), acquisitionId (0-based index into PermanentCollection's acquisition log), sequence (1-based collection order, diverges from tokenId), and mintedAtBlock. The same values are frozen in proofMeta(tokenId).

PunkLocked

solidity
event PunkLocked(uint16 indexed punkId)

Emitted exactly once per Punk that enters the vault. By design there is no counterpart "released" event; the PunkLocked log is the canonical append-only list of vaulted Punks.

RendererRegistrySet

solidity
event RendererRegistrySet(address indexed registry)

Emitted once, at the one-shot setRendererRegistry wiring.

TitleAuctionSet

solidity
event TitleAuctionSet(address indexed auction)

Emitted once, at the one-shot setTitleAuction wiring.

TitleMinted

solidity
event TitleMinted(address indexed to)

Emitted once, at Title mint time, with the title auction contract as to. Mirrors the ERC721 Transfer(address(0), titleAuction, 111) for indexers that key on a protocol-specific event name.

Transfer

solidity
event Transfer(address indexed from, address indexed to, uint256 indexed id)

Standard ERC721 transfer event for Proofs and the Title, including the mint-time transfers from address(0). Never fired for Punks; Punk movements are events on the CryptoPunks market contract.

Errors

AlreadyLocked(uint16 punkId)

receivePunk was called for a Punk already registered as vaulted. Each Punk locks at most once; there is nothing for the caller to do.

InvalidRecipient()

mintProofs was called with recipient == address(0). Defense in depth: PermanentCollection.recordAcquisition already enforces a non-zero originalSeller.

InvalidTraitId(uint8 traitId)

mintProofs was called with traitId >= 111. Trait ids run 0..110; the id is out of the taxonomy.

NotDeployer()

setTitleAuction or setRendererRegistry was called by an address other than the deployer EOA. These are launch-wiring functions, not integrator surface.

NotOwnedByVault(uint16 punkId)

receivePunk ran before the Punk was actually transferred to the vault on the CryptoPunks market. The module must execute transferPunk(vault, punkId) first; the check keeps the vaulted list honest.

NotOwner()

renounceOwnership was called by an address other than the current owner(). Once renounced, no caller can ever satisfy the check again.

NotReturnAuction()

receivePunk or mintProofs was called by an address other than the immutable returnAuctionModule. These functions aren't callable by integrators.

NotTitleAuction()

mintToAuction was called by an address other than the wired titleAuction contract. Use the title auction's own mintTitle() entry point.

ProofAlreadyMinted(uint8 traitId)

mintProofs was called for a trait whose Proof already exists. Each trait's Proof mints exactly once, at the trait's first vaulting.

RendererRegistryAlreadySet()

Second call to the one-shot setRendererRegistry. The registry slot is write-once.

RendererRegistryNotSet()

tokenURI or contractURI was called before the registry was wired. Not reachable on mainnet: the wiring is part of the deploy broadcast.

TitleAlreadyMinted()

Second call to mintToAuction. The Title is a one-of-one; it mints exactly once.

TitleAuctionAlreadySet()

Second call to the one-shot setTitleAuction. The title-auction slot is write-once.

TitleNotMinted()

tokenURI(111) was queried before the Title exists. Check titleMinted() before querying, or treat the revert as "not yet issued".

UnknownTokenId(uint256 id)

tokenURI(id) was queried for an unminted Proof id (0..110 with its proofsMintedMask bit unset) or any id at or above 112. For Proof ids, check isProofMinted(uint8(id)) before querying.

ZeroAddress()

A constructor argument or one-shot wiring target (setTitleAuction, setRendererRegistry) was address(0).

PunkVault · Permanent Collection