ChibiCoreV2

0x60cb7082c8c14b4237c6a24c65e7c2e7abe2bd17

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
8,938 bytes
ABI entries
75
18 read · 20 write
License
none

Contract information

Address
0x60cb7082c8c14b4237c6a24c65e7c2e7abe2bd17
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x23770831b6…8eea2e2741

Token

Name
Urufu Gēmu
Symbol
CHIBI
Decimals
Holders
299

Read contract (18)

MAX_AIRDROP_ID()uint256
airdropClosed()bool
airdropMintedCount()uint256
artSlotOf(uint256)uint32
balanceOf(address)uint256
getApproved(uint256)address
isApprovedForAll(address, address)bool
isController(address)bool
name()string
nextContinuationId()uint256
owner()address
ownerOf(uint256)address
pendingOwner()address
raidPowerOf(uint256)uint256
roleOf(uint256)uint8
supportsInterface(bytes4)bool
symbol()string
tokenURI(uint256)string

Events (15)

AirdropClosedEventApprovalApprovalForAllArtSlotAssignedBaseURIUpdatedBatchMetadataUpdateChibiBurnedChibiMintedControllerAddedControllerRemovedMetadataUpdateOwnershipTransferStartedOwnershipTransferredRaidPowerSetTransfer

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "initialOwner",
        "type": "address"
      },
      {
        "internalType": "string",
        "name": "initialBaseURI",
        "type": "string"
      },
      {
        "internalType": "uint256",
        "name": "maxAirdropId",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AirdropAlreadyClosed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "AirdropClosed",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "actualTotalSupply",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "expectedTotalSupply",
        "type": "uint256"
      }
    ],
    "name": "AirdropIncomplete",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "sender",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "ERC721IncorrectOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ERC721InsufficientApproval",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "approver",
        "type": "address"
      }
    ],
    "name": "ERC721InvalidApprover",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "ERC721InvalidOperator",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "ERC721InvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "receiver",
        "type": "address"
      }
    ],
    "name": "ERC721InvalidReceiver",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "sender",
        "type": "address"
      }
    ],
    "name": "ERC721InvalidSender",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ERC721NonexistentToken",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidBatchMint",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidTokenId",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidTokenRange",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "LengthMismatch",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotController",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TokenAlreadyMinted",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TokenDoesNotExist",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "highestAirdroppedId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "continuationStart",
        "type": "uint256"
      }
    ],
    "name": "AirdropClosedEvent",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "approved",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Approval",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "ApprovalForAll",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "enum GameTypes.Role",
        "name": "role",
        "type": "uint8"
      },
      {
        "indexed": false,
        "internalType": "uint32",
        "name": "artSlot",
        "type": "uint32"
      }
    ],
    "name": "ArtSlotAssigned",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "string",
        "name": "newBaseURI",
        "type": "string"
      }
    ],
    "name": "BaseURIUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "_fromTokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "_toTokenId",
        "type": "uint256"
      }
    ],
    "name": "BatchMetadataUpdate",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ChibiBurned",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "enum GameTypes.Role",
        "name": "role",
        "type": "uint8"
      }
    ],
    "name": "ChibiMinted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "controller",
        "type": "address"
      }
    ],
    "name": "ControllerAdded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "controller",
        "type": "address"
      }
    ],
    "name": "ControllerRemoved",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "_tokenId",
        "type": "uint256"
      }
    ],
    "name": "MetadataUpdate",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferStarted",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "raidPower",
        "type": "uint256"
      }
    ],
    "name": "RaidPowerSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "Transfer",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "MAX_AIRDROP_ID",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "acceptOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "controller",
        "type": "address"
      }
    ],
    "name": "addController",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "airdropClosed",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "enum GameTypes.Role",
        "name": "role",
        "type": "uint8"
      },
      {
        "internalType": "uint32",
        "name": "artSlot",
        "type": "uint32"
      },
      {
        "internalType": "uint256",
        "name": "raidPower",
        "type": "uint256"
      }
    ],
    "name": "airdropMint",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[]",
        "name": "tos",
        "type": "address[]"
      },
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      },
      {
        "internalType": "enum GameTypes.Role[]",
        "name": "roles",
        "type": "uint8[]"
      },
      {
        "internalType": "uint32[]",
        "name": "artSlots",
        "type": "uint32[]"
      },
      {
        "internalType": "uint256[]",
        "name": "raidPowers",
        "type": "uint256[]"
      }
    ],
    "name": "airdropMintBatch",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "airdropMintedCount",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "approve",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "artSlotOf",
    "outputs": [
      {
        "internalType": "uint32",
        "name": "",
        "type": "uint32"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "balanceOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "enum GameTypes.Role[]",
        "name": "roles",
        "type": "uint8[]"
      },
      {
        "internalType": "uint256[]",
        "name": "raidPowers",
        "type": "uint256[]"
      },
      {
        "internalType": "uint32[]",
        "name": "artSlots",
        "type": "uint32[]"
      }
    ],
    "name": "batchMint",
    "outputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "fromTokenId",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "toTokenId",
        "type": "uint256"
      }
    ],
    "name": "broadcastMetadataUpdate",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "burn",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "expectedTotalSupply",
        "type": "uint256"
      }
    ],
    "name": "closeAirdrop",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "getApproved",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      }
    ],
    "name": "isApprovedForAll",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "isController",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "enum GameTypes.Role",
        "name": "role",
        "type": "uint8"
      }
    ],
    "name": "mint",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "enum GameTypes.Role",
        "name": "role",
        "type": "uint8"
      },
      {
        "internalType": "uint32",
        "name": "artSlot",
        "type": "uint32"
      }
    ],
    "name": "mintWithArt",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "name",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nextContinuationId",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "ownerOf",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "pendingOwner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "raidPowerOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "controller",
        "type": "address"
      }
    ],
    "name": "removeController",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "roleOf",
    "outputs": [
      {
        "internalType": "enum GameTypes.Role",
        "name": "",
        "type": "uint8"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "safeTransferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "approved",
        "type": "bool"
      }
    ],
    "name": "setApprovalForAll",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "string",
        "name": "newBaseURI",
        "type": "string"
      }
    ],
    "name": "setBaseURI",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "raidPower",
        "type": "uint256"
      }
    ],
    "name": "setRaidPower",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes4",
        "name": "interfaceId",
        "type": "bytes4"
      }
    ],
    "name": "supportsInterface",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "symbol",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "tokenURI",
    "outputs": [
      {
        "internalType": "string",
        "name": "",
        "type": "string"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "transferFrom",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";

import {GameTypes} from "./libraries/GameTypes.sol";

/// @title ChibiCoreV2
/// @notice Robinhood-side ChibiCore for the Base → RH airdrop cutover.
///         Functionally equivalent to `ChibiCore` (v1: roles, artSlots,
///         raid power, controller gating, ERC-4906 metadata broadcasts) but
///         built on OpenZeppelin ERC721 rather than ERC721A so it can mint
///         out-of-order specific tokenIds during the airdrop distribution.
///
///         Two mint paths coexist from deploy:
///
///         1. `airdropMint(...)` / `airdropMintBatch(...)` — owner only.
///            Used by the deployer / multisig to reproduce a Base chibi at
///            its exact tokenId, role, and art slot. Callable until
///            `closeAirdrop()`.
///
///         2. IChibiCore continuation mints (`mint` / `mintWithArt` /
///            `batchMint`) — controller only. Assigns sequential ids from
///            `_nextContinuationId` (initialized to `MAX_AIRDROP_ID + 1` in
///            the constructor). Available from deploy; not gated by the
///            airdrop close switch.
///
/// @dev    ── Why not ERC721A ──
///         ERC721A packs sequential mint metadata into a single storage slot,
///         efficient for `mintQuantity(N)` but incompatible with the "mint
///         tokenId #47 first, then #12, then #200" pattern the airdrop needs
///         to preserve v1 identity. Standard OZ ERC721 pays per-token mint
///         gas; that's fine because airdrop distribution is a one-time deploy
///         event batched by the owner.
///
///         ── Why role/artSlot mirror v1 ──
///         Role is immutable at mint (like on v1). Art slot is set once at
///         mint and never changes. This preserves "same chibi identity"
///         across the airdrop — from the holder's perspective, RH chibi
///         #47 IS Base chibi #47.
contract ChibiCoreV2 is ERC721, Ownable2Step {
    using Strings for uint256;

    /// @dev ERC-4906 interface ID.
    bytes4 private constant _INTERFACE_ID_ERC4906 = 0x49064906;

    // ─── Errors ─────────────────────────────────────────────────────
    error NotController();
    error ZeroAddress();
    error TokenDoesNotExist();
    error InvalidBatchMint();
    error InvalidTokenId();
    error TokenAlreadyMinted();
    error AirdropClosed();
    error AirdropAlreadyClosed();
    error AirdropIncomplete(uint256 actualTotalSupply, uint256 expectedTotalSupply);
    error InvalidTokenRange();
    error LengthMismatch();

    // ─── Events ─────────────────────────────────────────────────────
    event ControllerAdded(address indexed controller);
    event ControllerRemoved(address indexed controller);
    event BaseURIUpdated(string newBaseURI);
    event ChibiMinted(uint256 indexed tokenId, address indexed to, GameTypes.Role role);
    event ArtSlotAssigned(uint256 indexed tokenId, GameTypes.Role role, uint32 artSlot);
    event RaidPowerSet(uint256 indexed tokenId, uint256 raidPower);
    event ChibiBurned(uint256 indexed tokenId);
    event AirdropClosedEvent(uint256 highestAirdroppedId, uint256 continuationStart);

    /// @dev EIP-4906 events declared locally.
    event MetadataUpdate(uint256 _tokenId);
    event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);

    // ─── Storage ────────────────────────────────────────────────────
    /// @notice Highest tokenId reservable for airdrop (inclusive). Set at
    ///         deploy to the Base snapshot's highest minted tokenId.
    uint256 public immutable MAX_AIRDROP_ID;

    mapping(address account => bool allowed) private _controllers;
    mapping(uint256 tokenId => GameTypes.Role role) private _roles;
    mapping(uint256 tokenId => uint32 slot) private _artSlots;
    mapping(uint256 tokenId => uint256 power) private _raidPower;

    /// @notice Next tokenId assigned by continuation `mint()`. Initialized
    ///         to `MAX_AIRDROP_ID + 1` in the constructor so ranges are
    ///         disjoint even while airdrop is open.
    uint256 private _nextContinuationId;

    /// @notice True once `closeAirdrop()` has been called; airdrop mints
    ///         are permanently locked out.
    bool public airdropClosed;

    /// @notice Count of airdrop mints so far (incremented in _airdropMintOne).
    ///         Read by `closeAirdrop(expectedTotalSupply)` to gate the
    ///         permanent close against fat-finger before all chibis mint.
    ///         NOT the same as `_nextContinuationId - MAX_AIRDROP_ID - 1`;
    ///         that counts continuation mints only. This counts airdrop mints.
    uint256 public airdropMintedCount;

    string private _baseTokenURI;

    modifier onlyController() {
        if (!_controllers[msg.sender]) revert NotController();
        _;
    }

    // ─── Constructor ────────────────────────────────────────────────
    /// @param initialOwner    Deploy-time owner. Handles airdrop mints, then
    ///                        transfers to multisig via Ownable2Step.
    /// @param initialBaseURI  Metadata prefix (same CDN as v1 for identity parity).
    /// @param maxAirdropId    Highest tokenId reservable for airdrop. Set to
    ///                        the highest minted tokenId on the Base snapshot.
    constructor(address initialOwner, string memory initialBaseURI, uint256 maxAirdropId)
        ERC721(unicode"Urufu Gēmu", "CHIBI")
        Ownable(_requireNonZero(initialOwner))
    {
        if (maxAirdropId == 0) revert InvalidTokenId();
        _baseTokenURI = initialBaseURI;
        MAX_AIRDROP_ID = maxAirdropId;
        _nextContinuationId = maxAirdropId + 1;
    }

    // ─── Views ──────────────────────────────────────────────────────

    function roleOf(uint256 tokenId) external view returns (GameTypes.Role) {
        _requireMinted(tokenId);
        return _roles[tokenId];
    }

    function artSlotOf(uint256 tokenId) external view returns (uint32) {
        _requireMinted(tokenId);
        return _artSlots[tokenId];
    }

    function raidPowerOf(uint256 tokenId) external view returns (uint256) {
        _requireMinted(tokenId);
        return _raidPower[tokenId];
    }

    function isController(address account) external view returns (bool) {
        return _controllers[account];
    }

    function nextContinuationId() external view returns (uint256) {
        return _nextContinuationId;
    }

    function tokenURI(uint256 tokenId) public view override returns (string memory) {
        _requireMinted(tokenId);
        return bytes(_baseTokenURI).length == 0 ? "" : string.concat(_baseTokenURI, tokenId.toString());
    }

    function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) {
        return interfaceId == _INTERFACE_ID_ERC4906 || super.supportsInterface(interfaceId);
    }

    // ─── Airdrop mint (owner-only, pre-close) ───────────────────────

    /// @notice Mint a chibi at a specific v1 tokenId to `to`.
    /// @dev    Owner-only. Reverts once `closeAirdrop()` has been called.
    ///         TokenId must be in `[1, MAX_AIRDROP_ID]` and un-minted.
    ///         Uses `_mint` (not `_safeMint`) — recipient is the same address
    ///         that held the chibi on Base. That address is proven able to
    ///         hold ERC-721s. `_safeMint`'s receiver check would otherwise
    ///         silently trap EIP-7702-delegated EOAs whose delegate does not
    ///         implement `IERC721Receiver`.
    function airdropMint(
        address to,
        uint256 tokenId,
        GameTypes.Role role,
        uint32 artSlot,
        uint256 raidPower
    ) external onlyOwner {
        _airdropMintOne(to, tokenId, role, artSlot, raidPower);
    }

    /// @notice Batched airdrop mint — mints many chibis (each to its own
    ///         recipient) in one tx. Per-index arrays parallel each other.
    /// @dev    Owner-only. Reverts once `closeAirdrop()` has been called.
    ///         Skip-if-already-minted is intentional to make batch retries
    ///         idempotent (a script re-run after a partial tx doesn't fail
    ///         on the successfully-minted head of the batch).
    function airdropMintBatch(
        address[] calldata tos,
        uint256[] calldata tokenIds,
        GameTypes.Role[] calldata roles,
        uint32[] calldata artSlots,
        uint256[] calldata raidPowers
    ) external onlyOwner {
        uint256 n = tos.length;
        if (n == 0) revert InvalidBatchMint();
        if (
            tokenIds.length != n || roles.length != n || artSlots.length != n
                || raidPowers.length != n
        ) revert LengthMismatch();

        for (uint256 i = 0; i < n; i++) {
            uint256 id = tokenIds[i];
            // Skip already-minted for batch idempotency.
            if (_ownerOf(id) != address(0)) continue;
            _airdropMintOne(tos[i], id, roles[i], artSlots[i], raidPowers[i]);
        }
    }

    /// @notice Permanently close the airdrop mint path. Continuation mints
    ///         are unaffected — they were running from constructor time.
    /// @dev    Owner-only, one-shot. Requires the operator to pass the
    ///         expected total supply, so a fat-finger close-before-all-mints
    ///         reverts with a clear error instead of permanently locking the
    ///         airdrop path partway through. The operator computes this off
    ///         chain (== chibi count from the airdrop plan JSON, currently
    ///         1905 on Base at snapshot) and passes it in. Rehearsal /
    ///         runbook enforce that this equals `plan.totals.chibiCount`.
    /// @param  expectedTotalSupply airdrop plan's chibi count. `totalSupply()`
    ///         must equal this exactly before the close is allowed.
    function closeAirdrop(uint256 expectedTotalSupply) external onlyOwner {
        if (airdropClosed) revert AirdropAlreadyClosed();
        uint256 actual = airdropMintedCount;
        if (actual != expectedTotalSupply) {
            revert AirdropIncomplete(actual, expectedTotalSupply);
        }
        airdropClosed = true;
        emit AirdropClosedEvent(MAX_AIRDROP_ID, _nextContinuationId);
    }

    // ─── Continuation mint (IChibiCore surface) ─────────────────────

    function mint(address to, GameTypes.Role role) external onlyController returns (uint256 tokenId) {
        return _continuationMint(to, role, 0, 0);
    }

    function mintWithArt(address to, GameTypes.Role role, uint32 artSlot)
        external
        onlyController
        returns (uint256 tokenId)
    {
        return _continuationMint(to, role, artSlot, 0);
    }

    function batchMint(
        address to,
        GameTypes.Role[] calldata roles,
        uint256[] calldata raidPowers,
        uint32[] calldata artSlots
    ) external onlyController returns (uint256[] memory tokenIds) {
        if (to == address(0)) revert ZeroAddress();
        uint256 quantity = roles.length;
        if (quantity == 0 || quantity != raidPowers.length || quantity != artSlots.length) {
            revert InvalidBatchMint();
        }

        tokenIds = new uint256[](quantity);
        for (uint256 i = 0; i < quantity; i++) {
            tokenIds[i] = _continuationMint(to, roles[i], artSlots[i], raidPowers[i]);
        }
    }

    function setRaidPower(uint256 tokenId, uint256 raidPower) external onlyController {
        _requireMinted(tokenId);
        _raidPower[tokenId] = raidPower;
        emit RaidPowerSet(tokenId, raidPower);
    }

    function burn(uint256 tokenId) external onlyController {
        _requireMinted(tokenId);
        _burn(tokenId);
        delete _roles[tokenId];
        delete _artSlots[tokenId];
        delete _raidPower[tokenId];
        emit ChibiBurned(tokenId);
    }

    // ─── Owner admin ────────────────────────────────────────────────

    function addController(address controller) external onlyOwner {
        if (controller == address(0)) revert ZeroAddress();
        _controllers[controller] = true;
        emit ControllerAdded(controller);
    }

    function removeController(address controller) external onlyOwner {
        if (controller == address(0)) revert ZeroAddress();
        _controllers[controller] = false;
        emit ControllerRemoved(controller);
    }

    function setBaseURI(string calldata newBaseURI) external onlyOwner {
        _baseTokenURI = newBaseURI;
        emit BaseURIUpdated(newBaseURI);
        emit BatchMetadataUpdate(1, MAX_AIRDROP_ID);
    }

    function broadcastMetadataUpdate(uint256 fromTokenId, uint256 toTokenId) external onlyOwner {
        if (fromTokenId == 0 || fromTokenId > toTokenId) revert InvalidTokenRange();
        emit BatchMetadataUpdate(fromTokenId, toTokenId);
    }

    // ─── Internals ──────────────────────────────────────────────────

    function _airdropMintOne(
        address to,
        uint256 tokenId,
        GameTypes.Role role,
        uint32 artSlot,
        uint256 raidPower
    ) private {
        if (airdropClosed) revert AirdropClosed();
        if (to == address(0)) revert ZeroAddress();
        if (tokenId == 0 || tokenId > MAX_AIRDROP_ID) revert InvalidTokenId();
        if (_ownerOf(tokenId) != address(0)) revert TokenAlreadyMinted();

        _roles[tokenId] = role;
        _artSlots[tokenId] = artSlot;
        if (raidPower > 0) {
            _raidPower[tokenId] = raidPower;
            emit RaidPowerSet(tokenId, raidPower);
        }
        _mint(to, tokenId);
        unchecked { airdropMintedCount++; }

        emit ChibiMinted(tokenId, to, role);
        emit ArtSlotAssigned(tokenId, role, artSlot);
    }

    function _continuationMint(address to, GameTypes.Role role, uint32 artSlot, uint256 raidPower)
        private
        returns (uint256 tokenId)
    {
        if (to == address(0)) revert ZeroAddress();

        tokenId = _nextContinuationId++;
        _roles[tokenId] = role;
        _artSlots[tokenId] = artSlot;
        if (role == GameTypes.Role.Wolf && raidPower > 0) {
            _raidPower[tokenId] = raidPower;
            emit RaidPowerSet(tokenId, raidPower);
        }
        _safeMint(to, tokenId);

        emit ChibiMinted(tokenId, to, role);
        emit ArtSlotAssigned(tokenId, role, artSlot);
    }

    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    function _requireMinted(uint256 tokenId) private view {
        if (_ownerOf(tokenId) == address(0)) revert TokenDoesNotExist();
    }

    function _requireNonZero(address account) private pure returns (address) {
        if (account == address(0)) revert ZeroAddress();
        return account;
    }
}
Chain explorer1986msChain node76ms