HuskNFT
0xcef60c5df1a96e3349f7fdfcdb575fb7d13fd9fe
Verification
Verified
v0.8.27+commit.40a35a09
Type
Contract
19,573 bytes
ABI entries
135
50 read · 39 write
License
none
Contract information
- Address
- 0xcef60c5df1a96e3349f7fdfcdb575fb7d13fd9fe
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.27+commit.40a35a09
- Optimization
- Enabled
- Creator
- 0x9004b2dc1F…63234C6d30
- Creation tx
- 0xaae8f8e3da…f248a15fce
Token
- Name
- Husks
- Symbol
- HUSKS
- Decimals
- —
- Holders
- 1,069
- Market
- View token page →
Read contract (50)
KEEPER_BOUNTY_FLOOR() → uint256
MAX_PER_WALLET() → uint256
MAX_ROYALTY_BPS() → uint96
MAX_SUPPLY_CEILING() → uint256
accountImplementation() → address
allowlistPrice() → uint256
allowlistRoot() → bytes32
allowlisted(address) → bool
arenaContract() → address
balanceOf(address) → uint256
contractURI() → string
dailyLifeSupport() → uint256
eoaOnlyMint() → bool
freeMintThreshold() → uint256
fuelOracle() → address
fuelOracleBand() → uint256
fuelPerDay() → uint256
fuelToken() → address
getApproved(uint256) → address
getHusk(uint256) → tuple
getHuskAccount(uint256) → address
getHusks(uint256[]) → tuple[]
huskAccounts(uint256) → address
huskToken() → address
isAllowlisted(address, bytes32[]) → bool
isApprovedForAll(address, address) → bool
isFreeMinter(address) → bool
lastLook() → uint32
manualFuelPerDay() → uint256
maxSupply() → uint256
mintPhase() → uint8
mintPrice() → uint256
mintPriceFor(address) → uint256
mintRouter(address) → bool
mintedPerWallet(address) → uint256
name() → string
owner() → address
ownerOf(uint256) → address
rebornDeposit() → uint256
registry() → address
renderer() → address
rendererFrozen() → bool
revealDeposit() → uint256
royaltyInfo(uint256, uint256) → address, uint256
supportsInterface(bytes4) → bool
symbol() → string
tokenURI(uint256) → string
tokensOfOwner(address) → uint256[]
totalMinted() → uint256
treasury() → address
Events (23)
AllowlistPriceSetAllowlistRootSetAllowlistSetApprovalApprovalForAllArenaContractSetBatchMetadataUpdateContractURISetFreeMintConfigSetFuelConfigSetFuelOracleBandSetFuelOracleSetHuskSummonedLifecycleCostsSetLookRevealedMaxSupplyIncreasedMetadataUpdateMintPhaseSetMintRouterSetOwnershipTransferredRendererFrozenRendererSetTransfer
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_registry",
"type": "address"
},
{
"internalType": "address",
"name": "_accountImpl",
"type": "address"
},
{
"internalType": "address",
"name": "_treasury",
"type": "address"
},
{
"internalType": "uint256",
"name": "_mintPrice",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "_maxSupply",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "AllowlistClosed",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "numerator",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "denominator",
"type": "uint256"
}
],
"name": "ERC2981InvalidDefaultRoyalty",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "receiver",
"type": "address"
}
],
"name": "ERC2981InvalidDefaultRoyaltyReceiver",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "numerator",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "denominator",
"type": "uint256"
}
],
"name": "ERC2981InvalidTokenRoyalty",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "receiver",
"type": "address"
}
],
"name": "ERC2981InvalidTokenRoyaltyReceiver",
"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": "InsufficientPayment",
"type": "error"
},
{
"inputs": [],
"name": "MaxSupplyReached",
"type": "error"
},
{
"inputs": [],
"name": "MintCapReached",
"type": "error"
},
{
"inputs": [],
"name": "NotAllowlisted",
"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": "PublicMintClosed",
"type": "error"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "value",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "length",
"type": "uint256"
}
],
"name": "StringsInsufficientHexLength",
"type": "error"
},
{
"inputs": [],
"name": "TokenDoesNotExist",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "price",
"type": "uint256"
}
],
"name": "AllowlistPriceSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "bytes32",
"name": "root",
"type": "bytes32"
}
],
"name": "AllowlistRootSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "account",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "status",
"type": "bool"
}
],
"name": "AllowlistSet",
"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": false,
"internalType": "address",
"name": "arena",
"type": "address"
}
],
"name": "ArenaContractSet",
"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": [],
"name": "ContractURISet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "huskToken",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "threshold",
"type": "uint256"
}
],
"name": "FreeMintConfigSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "perDay",
"type": "uint256"
}
],
"name": "FuelConfigSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "band",
"type": "uint256"
}
],
"name": "FuelOracleBandSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "oracle",
"type": "address"
}
],
"name": "FuelOracleSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "necromancer",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "address",
"name": "tbaAddress",
"type": "address"
}
],
"name": "HuskSummoned",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "revealDeposit",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "dailyLifeSupport",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "rebornDeposit",
"type": "uint256"
}
],
"name": "LifecycleCostsSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint32",
"name": "look",
"type": "uint32"
}
],
"name": "LookRevealed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "oldMax",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "newMax",
"type": "uint256"
}
],
"name": "MaxSupplyIncreased",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "_tokenId",
"type": "uint256"
}
],
"name": "MetadataUpdate",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "enum HuskNFT.Phase",
"name": "phase",
"type": "uint8"
}
],
"name": "MintPhaseSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "router",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "allowed",
"type": "bool"
}
],
"name": "MintRouterSet",
"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": [],
"name": "RendererFrozen",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "address",
"name": "renderer",
"type": "address"
}
],
"name": "RendererSet",
"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": "KEEPER_BOUNTY_FLOOR",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_PER_WALLET",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_ROYALTY_BPS",
"outputs": [
{
"internalType": "uint96",
"name": "",
"type": "uint96"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_SUPPLY_CEILING",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "accountImplementation",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "allowlistPrice",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "allowlistRoot",
"outputs": [
{
"internalType": "bytes32",
"name": "",
"type": "bytes32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "allowlisted",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"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": [],
"name": "arenaContract",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "contractURI",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "dailyLifeSupport",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "eoaOnlyMint",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "freeMintThreshold",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "freezeRenderer",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "fuelOracle",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "fuelOracleBand",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "fuelPerDay",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "fuelToken",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "getApproved",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "getHusk",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "bool",
"name": "revealed",
"type": "bool"
},
{
"internalType": "bool",
"name": "pendingReveal",
"type": "bool"
},
{
"internalType": "uint16",
"name": "generation",
"type": "uint16"
},
{
"internalType": "uint8",
"name": "level",
"type": "uint8"
},
{
"internalType": "uint32",
"name": "wins",
"type": "uint32"
},
{
"internalType": "uint32",
"name": "losses",
"type": "uint32"
},
{
"internalType": "uint16",
"name": "currentStreak",
"type": "uint16"
},
{
"internalType": "uint256",
"name": "accountBalance",
"type": "uint256"
},
{
"internalType": "string",
"name": "tokenURI",
"type": "string"
}
],
"internalType": "struct HuskNFT.HuskView",
"name": "v",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "getHuskAccount",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256[]",
"name": "tokenIds",
"type": "uint256[]"
}
],
"name": "getHusks",
"outputs": [
{
"components": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "bool",
"name": "revealed",
"type": "bool"
},
{
"internalType": "bool",
"name": "pendingReveal",
"type": "bool"
},
{
"internalType": "uint16",
"name": "generation",
"type": "uint16"
},
{
"internalType": "uint8",
"name": "level",
"type": "uint8"
},
{
"internalType": "uint32",
"name": "wins",
"type": "uint32"
},
{
"internalType": "uint32",
"name": "losses",
"type": "uint32"
},
{
"internalType": "uint16",
"name": "currentStreak",
"type": "uint16"
},
{
"internalType": "uint256",
"name": "accountBalance",
"type": "uint256"
},
{
"internalType": "string",
"name": "tokenURI",
"type": "string"
}
],
"internalType": "struct HuskNFT.HuskView[]",
"name": "out",
"type": "tuple[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "huskAccounts",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "huskToken",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
},
{
"internalType": "bytes32[]",
"name": "proof",
"type": "bytes32[]"
}
],
"name": "isAllowlisted",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"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": "who",
"type": "address"
}
],
"name": "isFreeMinter",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "lastLook",
"outputs": [
{
"internalType": "uint32",
"name": "",
"type": "uint32"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "manualFuelPerDay",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "maxSupply",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "quantity",
"type": "uint256"
}
],
"name": "mint",
"outputs": [
{
"internalType": "uint256[]",
"name": "",
"type": "uint256[]"
},
{
"internalType": "address[]",
"name": "",
"type": "address[]"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "quantity",
"type": "uint256"
},
{
"internalType": "bytes32[]",
"name": "proof",
"type": "bytes32[]"
}
],
"name": "mintAllowlist",
"outputs": [
{
"internalType": "uint256[]",
"name": "",
"type": "uint256[]"
},
{
"internalType": "address[]",
"name": "",
"type": "address[]"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [],
"name": "mintPhase",
"outputs": [
{
"internalType": "enum HuskNFT.Phase",
"name": "",
"type": "uint8"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "mintPrice",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "who",
"type": "address"
}
],
"name": "mintPriceFor",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "mintRouter",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "quantity",
"type": "uint256"
}
],
"name": "mintTo",
"outputs": [
{
"internalType": "uint256[]",
"name": "",
"type": "uint256[]"
},
{
"internalType": "address[]",
"name": "",
"type": "address[]"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "mintedPerWallet",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "notifyMetadataUpdate",
"outputs": [],
"stateMutability": "nonpayable",
"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": "rebornDeposit",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "refreshMetadata",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "fromTokenId",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "toTokenId",
"type": "uint256"
}
],
"name": "refreshMetadataRange",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "registry",
"outputs": [
{
"internalType": "contract ERC6551Registry",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renderer",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "rendererFrozen",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "uint32",
"name": "look",
"type": "uint32"
}
],
"name": "reportLook",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "revealDeposit",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "salePrice",
"type": "uint256"
}
],
"name": "royaltyInfo",
"outputs": [
{
"internalType": "address",
"name": "receiver",
"type": "address"
},
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"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": "accounts",
"type": "address[]"
},
{
"internalType": "bool",
"name": "status",
"type": "bool"
}
],
"name": "setAllowlist",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "price",
"type": "uint256"
}
],
"name": "setAllowlistPrice",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes32",
"name": "root",
"type": "bytes32"
}
],
"name": "setAllowlistRoot",
"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": "address",
"name": "_arena",
"type": "address"
}
],
"name": "setArenaContract",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "string",
"name": "uri",
"type": "string"
}
],
"name": "setContractURI",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "v",
"type": "uint256"
}
],
"name": "setDailyLifeSupport",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bool",
"name": "_on",
"type": "bool"
}
],
"name": "setEoaOnlyMint",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "setFreeMintThreshold",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "oracle",
"type": "address"
}
],
"name": "setFuelOracle",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "band",
"type": "uint256"
}
],
"name": "setFuelOracleBand",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "perDay",
"type": "uint256"
}
],
"name": "setFuelPerDay",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "setFuelToken",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "setHuskToken",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "newMax",
"type": "uint256"
}
],
"name": "setMaxSupply",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "enum HuskNFT.Phase",
"name": "phase",
"type": "uint8"
}
],
"name": "setMintPhase",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "_price",
"type": "uint256"
}
],
"name": "setMintPrice",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "router",
"type": "address"
},
{
"internalType": "bool",
"name": "allowed",
"type": "bool"
}
],
"name": "setMintRouter",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "v",
"type": "uint256"
}
],
"name": "setRebornDeposit",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_renderer",
"type": "address"
}
],
"name": "setRenderer",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "v",
"type": "uint256"
}
],
"name": "setRevealDeposit",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint96",
"name": "bps",
"type": "uint96"
}
],
"name": "setRoyalty",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "_treasury",
"type": "address"
}
],
"name": "setTreasury",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "summon",
"outputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "tba",
"type": "address"
}
],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "quantity",
"type": "uint256"
}
],
"name": "summonBatch",
"outputs": [
{
"internalType": "uint256[]",
"name": "tokenIds",
"type": "uint256[]"
},
{
"internalType": "address[]",
"name": "tbas",
"type": "address[]"
}
],
"stateMutability": "payable",
"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": "owner",
"type": "address"
}
],
"name": "tokensOfOwner",
"outputs": [
{
"internalType": "uint256[]",
"name": "ids",
"type": "uint256[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalMinted",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"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"
},
{
"inputs": [],
"name": "treasury",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.27;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
import "@openzeppelin/contracts/interfaces/IERC4906.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "./ERC6551Registry.sol";
import "./HuskAccount.sol";
import "./IFuelOracle.sol";
import {IHuskRenderer} from "./render/IHuskRenderer.sol";
/// @dev Minimal $HUSK balance read for the free-mint tier.
interface IHuskBalance {
function balanceOf(address account) external view returns (uint256);
}
/**
* @title HuskNFT
* @notice ERC-721 that auto-deploys Token Bound Accounts on mint.
* Each NFT represents ownership of a Husk — an autonomous AI fighter.
*
* Summon → Creates NFT → Deploys TBA → Initializes brain + art + stats
*/
contract HuskNFT is ERC721, ERC2981, IERC4906, Ownable {
using Strings for uint256;
// ─────────────────────── Config ───────────────────────
uint256 public mintPrice; // PUBLIC-phase price per Husk
uint256 public maxSupply; // mint cap; raised only via setMaxSupply (increase-only, ceiling-bound)
/// @notice Hard ceiling on maxSupply. setMaxSupply can only ever raise the cap up to this bound, which
/// is far below MercenaryHusk.MERC_BASE (1e9) so permanent + mercenary ids can never collide,
/// and so holders' worst-case dilution is a KNOWN, immutable bound rather than open-ended.
uint256 public constant MAX_SUPPLY_CEILING = 1_000_000;
uint256 public totalMinted;
// ── Mint phases + whitelist ──
enum Phase { Closed, Allowlist, Public }
/// @notice Current mint phase. Starts Closed; owner opens Allowlist then Public.
Phase public mintPhase;
/// @notice Merkle root of the allowlist (leaf = keccak256(abi.encodePacked(minter))).
bytes32 public allowlistRoot;
/// @notice Direct on-chain allowlist (owner-managed from the admin panel). A wallet may mint in the
/// Allowlist phase if it is either here OR proven by the merkle root — so small lists can be
/// managed live by address without rebuilding a root, and large drops still use the root.
mapping(address => bool) public allowlisted;
/// @notice Price per Husk during the Allowlist phase (may differ from the public price).
uint256 public allowlistPrice;
/// @notice Free-mint tier: a wallet holding >= freeMintThreshold of `huskToken` ($HUSK) mints at
/// price 0 — a growth flywheel that turns "I want a free mint" into "go buy $HUSK". Both are
/// owner-settable; set huskToken to address(0) OR threshold to 0 to disable the free tier.
/// All three tiers (free holder / allowlist / public) are live at once whenever mint is open.
address public huskToken;
uint256 public freeMintThreshold;
event FreeMintConfigSet(address huskToken, uint256 threshold);
/// @notice Per-wallet mint cap (primary mints only; secondary transfers are unrestricted).
uint256 public constant MAX_PER_WALLET = 10;
mapping(address => uint256) public mintedPerWallet;
/// @notice When true, only EOAs (or allowlisted routers) may mint. Stops a contract factory
/// from minting from many fresh child addresses in ONE transaction to blow past the
/// per-wallet cap. It does NOT stop a scripted attacker rotating funded EOAs (inherent
/// to any free mint). The owner can relax it, or allowlist specific aggregator routers.
bool public eoaOnlyMint = true;
/// @notice Aggregator/relayer contracts blessed to mint on a user's behalf via mintTo() even
/// while eoaOnlyMint is on. Their mints still count against the RECIPIENT's per-wallet
/// cap, so a blessed router can't over-mint any single user.
mapping(address => bool) public mintRouter;
/// @notice Collection-level metadata URI (OpenSea/marketplace collection page). Owner-settable.
string private _contractURI;
/// @notice Secondary-sale royalty, capped so it can never be rugged upward.
uint96 public constant MAX_ROYALTY_BPS = 1000; // 10%
ERC6551Registry public immutable registry;
address public immutable accountImplementation;
address public treasury;
address public arenaContract;
// Art renderer (sprite classes). Swappable for art seasons until frozen, then immutable.
address public renderer;
bool public rendererFrozen;
// tokenId → TBA address
mapping(uint256 => address) public huskAccounts;
// Uniqueness registry: (class << 8 | colourway) + 1 of the most recent reveal (0 = none yet).
// Each emerging Husk avoids this look, so no reveal ever repeats the one before it.
uint32 public lastLook;
// ── Fuel: an ERC-20 that can prepay life support (as well as ETH) ──
/// @notice Owner-set fuel token (e.g. $HUSK once deployed). address(0) = disabled, ETH-only.
/// MUST be a trusted, standard ERC-20 (no fee-on-transfer / callback tokens): feedFuel
/// moves it with a plain transferFrom to the burn address.
address public fuelToken;
/// @notice Manual $HUSK per day of life support (0 = disabled). Used when no oracle is set — e.g.
/// while the token is still on its launchpad bonding curve (no Uniswap pool yet to peg to).
uint256 public manualFuelPerDay;
/// @notice Optional price oracle: once $HUSK graduates to a Uniswap pool, point this at a reader
/// (e.g. UniV3FuelOracle) and `fuelPerDay()` auto-tracks it. 0 = use the manual rate.
/// Swappable, so a v2/v4 graduation just means a different reader — the game never changes.
address public fuelOracle;
/// @notice Consumer-side sanity band (defense-in-depth). The oracle's rate is trusted only while it
/// stays within [manualFuelPerDay / band, manualFuelPerDay * band]; a reading outside that is
/// treated as a manipulated/thin pool and ignored in favour of the manual rate. So even a
/// buggy or un-clamped oracle against a shallow freshly-graduated pool can never price $HUSK
/// life support to dust (or absurdly high). Default 8x; tighten as the manual anchor tracks.
uint256 public fuelOracleBand = 8;
/// @notice $HUSK charged per day of life support. Returns the oracle's live price-pegged rate ONLY
/// when an oracle is set, a manual anchor exists, and the reading sits inside the sanity band;
/// otherwise the manual rate. try/catch + band + zero-guard so a broken, hostile, or wildly
/// mispriced oracle can never brick feeding or price life support to dust — it just falls back
/// to the manual number. The oracle only ever AUGMENTS a configured manual anchor (manual == 0
/// keeps fuel disabled regardless of the oracle), which is what makes the band meaningful.
function fuelPerDay() public view returns (uint256) {
uint256 manual = manualFuelPerDay;
address o = fuelOracle;
if (o != address(0) && manual > 0) {
try IFuelOracle(o).huskPerDay() returns (uint256 v) {
uint256 band = fuelOracleBand;
// within [manual/band, manual*band] -> trust the live oracle; else ignore it
if (v >= manual / band && v <= manual * band) return v;
} catch {}
}
return manual;
}
// ── Owner-tunable lifecycle costs (read by HuskAccount; re-price the game live, no redeploy) ──
/// @notice Built-in defaults match the original constants; the deploy script sets the launch tier
/// (cheaper). Reveal/reborn are floored at HuskAccount.EMERGE_BOUNTY (0.001) by the setters
/// so a reveal always covers the keeper. Life support may be set to anything (0 = free).
uint256 public constant KEEPER_BOUNTY_FLOOR = 0.001 ether; // matches HuskAccount.EMERGE_BOUNTY
uint256 public revealDeposit = 0.01 ether; // min ETH to awaken (fund-to-reveal; seeds upkeep)
uint256 public dailyLifeSupport = 0.001 ether; // upkeep drawn per day from a living Husk
uint256 public rebornDeposit = 0.05 ether; // min ETH to revive a starved Husk
// ─────────────────────── Events ───────────────────────
event HuskSummoned(
address indexed necromancer,
uint256 indexed tokenId,
address tbaAddress
);
event ArenaContractSet(address arena);
event RendererSet(address renderer);
event RendererFrozen();
event LookRevealed(uint256 indexed tokenId, uint32 look);
event MintRouterSet(address indexed router, bool allowed);
event ContractURISet();
event MintPhaseSet(Phase phase);
event MaxSupplyIncreased(uint256 oldMax, uint256 newMax);
event AllowlistRootSet(bytes32 root);
event AllowlistSet(address indexed account, bool status);
event AllowlistPriceSet(uint256 price);
event FuelConfigSet(address token, uint256 perDay);
event FuelOracleSet(address oracle);
event FuelOracleBandSet(uint256 band);
event LifecycleCostsSet(uint256 revealDeposit, uint256 dailyLifeSupport, uint256 rebornDeposit);
// ─────────────────────── Errors ───────────────────────
error InsufficientPayment();
error MaxSupplyReached();
error TokenDoesNotExist();
error MintCapReached();
error PublicMintClosed();
error AllowlistClosed();
error NotAllowlisted();
// ─────────────────────── Constructor ─────────────────────
constructor(
address _registry,
address _accountImpl,
address _treasury,
uint256 _mintPrice,
uint256 _maxSupply
) ERC721("Husks", "HUSKS") Ownable(msg.sender) { // NFT symbol HUSKS — distinct from the $HUSK ERC-20 (no ticker collision)
registry = ERC6551Registry(_registry);
accountImplementation = _accountImpl;
treasury = _treasury;
mintPrice = _mintPrice; // public price
allowlistPrice = _mintPrice; // default allowlist price = public price (owner can discount)
maxSupply = _maxSupply;
mintPhase = Phase.Closed; // deploy locked; owner opens Allowlist then Public
_setDefaultRoyalty(_treasury, 500); // 5% secondary royalty (ERC-2981) to the treasury
}
// ──────────────────── Summoning ────────────────────
// Standard-named entrypoints so third-party mint aggregators auto-detect the mint, plus the
// game-flavoured summon()/summonBatch() aliases. All routes share _summonTo, which keys the
// per-wallet cap on the RECIPIENT (so a shared router can't brick at 5 and can't over-mint any
// one user), enforces the 10k supply cap, deploys+initializes each token's ERC-6551 account,
// and forwards the (free-mint: zero) fee to the treasury.
/// @notice Allowlist mint — mints to the caller at the allowlist price, gated by a Merkle proof.
/// Open during the Allowlist phase and (for early supporters) the Public phase too.
function mintAllowlist(uint256 quantity, bytes32[] calldata proof)
external
payable
returns (uint256[] memory, address[] memory)
{
if (mintPhase == Phase.Closed) revert AllowlistClosed();
_requireEOA();
// Free-tier $HUSK holders mint free even via the WL path; otherwise must be on the allowlist.
uint256 price;
if (isFreeMinter(msg.sender)) {
price = 0;
} else {
if (!allowlisted[msg.sender] && !isAllowlisted(msg.sender, proof)) revert NotAllowlisted();
price = allowlistPrice;
}
return _mintCore(msg.sender, quantity, price);
}
/// @notice Is `account` on the allowlist for `proof`? (leaf = keccak256(abi.encodePacked(account)))
function isAllowlisted(address account, bytes32[] calldata proof) public view returns (bool) {
if (allowlistRoot == bytes32(0)) return false;
return MerkleProof.verify(proof, allowlistRoot, keccak256(abi.encodePacked(account)));
}
/// @notice Does `who` qualify for a FREE mint (holds >= freeMintThreshold of the $HUSK token)?
function isFreeMinter(address who) public view returns (bool) {
return huskToken != address(0)
&& freeMintThreshold != 0
&& IHuskBalance(huskToken).balanceOf(who) >= freeMintThreshold;
}
/// @notice The price `who` pays via mint()/summon(): 0 if a qualifying $HUSK holder, else the
/// on-chain-allowlist price if allowlisted, else the public price. (Merkle-proof allowlisters
/// pay allowlistPrice via mintAllowlist.) Lets the UI show + send the exact tier price.
function mintPriceFor(address who) external view returns (uint256) {
if (isFreeMinter(who)) return 0;
if (allowlisted[who]) return allowlistPrice;
return mintPrice;
}
/// @notice Standard mint entrypoint (mints to the caller). Aggregator-friendly name.
function mint(uint256 quantity) external payable returns (uint256[] memory, address[] memory) {
_requireEOA();
return _summonTo(msg.sender, quantity);
}
/// @notice Mint on behalf of a recipient — lets an allowlisted aggregator/relayer mint for a
/// user. The cap counts against `to`, and (when eoaOnlyMint is on) the caller must be
/// the recipient's EOA or an allowlisted router.
function mintTo(address to, uint256 quantity)
external
payable
returns (uint256[] memory, address[] memory)
{
require(to != address(0), "Mint to zero");
_requireEOA();
return _summonTo(to, quantity);
}
/// @notice Summon one Husk to yourself (alias for mint(1)).
function summon() external payable returns (uint256 tokenId, address tba) {
_requireEOA();
(uint256[] memory ids, address[] memory tbas) = _summonTo(msg.sender, 1);
return (ids[0], tbas[0]);
}
/// @notice Summon up to 5 Husks to yourself in one tx (alias for mint(quantity)).
function summonBatch(uint256 quantity)
external
payable
returns (uint256[] memory tokenIds, address[] memory tbas)
{
_requireEOA();
return _summonTo(msg.sender, quantity);
}
/// @dev Tiered mint, keyed on the RECIPIENT — all three tiers are live at once once mint is open:
/// $HUSK holder → free · on-chain allowlisted → allowlist price · everyone else → public price
/// (public tier is Public-phase only; holders + allowlist can also go in the Allowlist phase).
/// The merkle-proof allowlist path lives in mintAllowlist().
function _summonTo(address to, uint256 quantity)
internal
returns (uint256[] memory, address[] memory)
{
if (mintPhase == Phase.Closed) revert PublicMintClosed();
uint256 price;
if (isFreeMinter(to)) {
price = 0; // holds >= threshold $HUSK — free
} else if (allowlisted[to]) {
price = allowlistPrice; // on-chain allowlist — WL price
} else {
if (mintPhase != Phase.Public) revert PublicMintClosed(); // pure public: Public phase only
price = mintPrice;
}
return _mintCore(to, quantity, price);
}
/// @dev Mints `quantity` Husks to `to` at `price` each. Neural weights/stats/art are a pure
/// function of the per-token seed (entropy only — real identity is rolled later at emerge).
function _mintCore(address to, uint256 quantity, uint256 price)
internal
returns (uint256[] memory tokenIds, address[] memory tbas)
{
require(quantity > 0 && quantity <= MAX_PER_WALLET, "1-10 per tx");
if (msg.value < price * quantity) revert InsufficientPayment();
if (totalMinted + quantity > maxSupply) revert MaxSupplyReached();
if (mintedPerWallet[to] + quantity > MAX_PER_WALLET) revert MintCapReached();
mintedPerWallet[to] += quantity;
tokenIds = new uint256[](quantity);
tbas = new address[](quantity);
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = totalMinted;
totalMinted++;
_mint(to, tokenId);
address tba = registry.createAccount(
accountImplementation, block.chainid, address(this), tokenId, 0
);
huskAccounts[tokenId] = tba;
uint256 seed = uint256(
keccak256(abi.encodePacked(tokenId, to, block.prevrandao, block.timestamp, i))
);
HuskAccount(payable(tba)).initialize(seed);
tokenIds[i] = tokenId;
tbas[i] = tba;
emit HuskSummoned(to, tokenId, tba);
}
// Forward the fee (zero on a free mint) to the treasury.
if (msg.value > 0) {
(bool success, ) = treasury.call{value: msg.value}("");
require(success, "Treasury transfer failed");
}
}
// ──────────────────── On-Chain Metadata ────────────────────
/**
* @notice Fully on-chain tokenURI with SVG art and JSON metadata.
*/
function tokenURI(uint256 tokenId) public view override returns (string memory) {
if (huskAccounts[tokenId] == address(0)) revert TokenDoesNotExist();
HuskAccount husk = HuskAccount(payable(huskAccounts[tokenId]));
string memory name = _nameString(husk.huskName(), tokenId);
// Art + traits both come from the (freezable) renderer. Wrap BOTH so a renderer defect that
// reverts or gas-bombs for some seed can never PERMANENTLY brick metadata reads for
// marketplaces (especially once the renderer is frozen) — degrade to a minimal but valid JSON
// instead of reverting the whole tokenURI. Transfers never call tokenURI, so this is read-path
// resilience only. (audit L-4)
string memory svgBase64;
try husk.renderSVG() returns (string memory svg) {
svgBase64 = Base64.encode(bytes(svg));
} catch {
return _fallbackURI(name);
}
// Renderer traits fragment (leading comma). Dormant => ",{Status:Dormant}"; active => class+colourway.
bytes memory rTraits;
if (renderer != address(0)) {
try IHuskRenderer(renderer).traits(husk.getRenderState()) returns (string memory t) {
rTraits = bytes(t);
} catch {
return _fallbackURI(name);
}
}
string memory attributes;
string memory description;
if (husk.isActive()) {
// Revealed: full stat sheet + class traits.
attributes = string(abi.encodePacked(_statAttributes(husk), rTraits));
description = "On-chain AI fighter with INT8 neural brain. Born in The Trench.";
} else {
// Dormant/pre-reveal: total blackout — no stats, no class, just a sealed coffin.
// rTraits carries a leading comma; strip it so the attributes array stays valid JSON.
attributes = _dropLeadingComma(rTraits);
description = "A dormant Husk sealed in its coffin. Fund it to awaken and reveal the fighter within.";
}
string memory json = string(
abi.encodePacked(
'{"name":"', name,
'","description":"', description, '",',
'"image":"data:image/svg+xml;base64,', svgBase64, '",',
'"attributes":[', attributes, ']}'
)
);
return string(
abi.encodePacked(
"data:application/json;base64,",
Base64.encode(bytes(json))
)
);
}
/// @dev Minimal, self-contained metadata used when the renderer reverts, so tokenURI always returns
/// valid JSON (name + Status) instead of bricking. No external calls. (audit L-4)
function _fallbackURI(string memory name) internal pure returns (string memory) {
bytes memory json = abi.encodePacked(
'{"name":"', name,
'","description":"Metadata temporarily unavailable (renderer error).",',
'"attributes":[{"trait_type":"Status","value":"Unrenderable"}]}'
);
return string(abi.encodePacked("data:application/json;base64,", Base64.encode(json)));
}
/// @dev Full stat-sheet attributes (no leading/trailing comma). Split out to ease stack pressure.
function _statAttributes(HuskAccount husk) internal view returns (string memory) {
(
uint16 atk, uint16 def, uint16 spd, uint16 hp,
uint8 lvl, uint32 wins, uint32 losses,
uint16 streak, , , ,
) = husk.getFullStats();
return string(
abi.encodePacked(
_trait("Attack", atk), ',',
_trait("Defense", def), ',',
_trait("Speed", spd), ',',
_trait("Health", hp), ',',
_trait("Level", uint256(lvl)), ',',
_trait("Wins", uint256(wins)), ',',
_trait("Losses", uint256(losses)), ',',
_trait("Best Streak", uint256(streak))
)
);
}
/// @dev Drop a single leading comma so a lone traits fragment forms a valid attributes array.
function _dropLeadingComma(bytes memory b) internal pure returns (string memory) {
if (b.length == 0 || b[0] != ',') return string(b);
bytes memory out = new bytes(b.length - 1);
for (uint256 i = 1; i < b.length; i++) out[i - 1] = b[i];
return string(out);
}
function _trait(string memory traitType, uint256 value)
internal
pure
returns (string memory)
{
return string(
abi.encodePacked(
'{"trait_type":"', traitType,
'","value":', value.toString(), '}'
)
);
}
/// @dev bytes16 name → trimmed string; strips control chars, quotes and backslashes
/// so a hostile name can never corrupt the metadata JSON.
function _nameString(bytes16 nameBytes, uint256 tokenId) internal pure returns (string memory) {
if (nameBytes == bytes16(0)) return string(abi.encodePacked("HUSK-", tokenId.toString()));
bytes memory raw = new bytes(16);
uint256 n;
for (uint256 i = 0; i < 16; i++) {
bytes1 ch = nameBytes[i];
if (ch == 0) break;
if (ch < 0x20 || ch > 0x7E || ch == '"' || ch == 0x5C) continue;
raw[n++] = ch;
}
if (n == 0) return string(abi.encodePacked("HUSK-", tokenId.toString()));
bytes memory out = new bytes(n);
for (uint256 i = 0; i < n; i++) out[i] = raw[i];
return string(out);
}
/// @notice Called by a token's own bound account when its identity is revealed (emerge).
/// Records the revealed look so the NEXT reveal can be guaranteed to differ.
function reportLook(uint256 tokenId, uint32 look) external {
require(huskAccounts[tokenId] == msg.sender, "Only the token's account");
lastLook = look + 1; // +1 so 0 always means "no reveal yet"
emit LookRevealed(tokenId, look);
}
// ──────────────────── Admin ────────────────────
function setArenaContract(address _arena) external onlyOwner {
arenaContract = _arena;
emit ArenaContractSet(_arena);
}
/// @notice Point tokenURI at an art renderer. Blocked forever once frozen.
function setRenderer(address _renderer) external onlyOwner {
require(!rendererFrozen, "Renderer frozen");
renderer = _renderer;
emit RendererSet(_renderer);
}
/// @notice One-way latch: permanently lock the current renderer (art becomes immutable).
function freezeRenderer() external onlyOwner {
require(!rendererFrozen, "Already frozen");
rendererFrozen = true;
emit RendererFrozen();
}
/// @dev EOA-or-allowlisted-router mint guard (see eoaOnlyMint). Blocks unknown contract callers.
function _requireEOA() internal view {
if (eoaOnlyMint) {
require(msg.sender == tx.origin || mintRouter[msg.sender], "EOA only");
}
}
function setEoaOnlyMint(bool _on) external onlyOwner {
eoaOnlyMint = _on;
}
/// @notice Allowlist (or remove) an aggregator/relayer contract for mintTo().
function setMintRouter(address router, bool allowed) external onlyOwner {
mintRouter[router] = allowed;
emit MintRouterSet(router, allowed);
}
// ──────────────── Fuel token (ERC-20 life support) ────────────────
/// @notice Set the ERC-20 that can prepay life support (e.g. $HUSK). address(0) disables the
/// token path (ETH-only). ONLY set a trusted, standard ERC-20 — feedFuel assumes plain
/// transferFrom semantics (no fee-on-transfer, no transfer hooks).
function setFuelToken(address token) external onlyOwner {
fuelToken = token;
emit FuelConfigSet(token, manualFuelPerDay);
}
/// @notice Set the MANUAL $HUSK-per-day rate (used when no oracle is set). 0 disables.
function setFuelPerDay(uint256 perDay) external onlyOwner {
manualFuelPerDay = perDay;
emit FuelConfigSet(fuelToken, perDay);
}
/// @notice Point $HUSK life-support pricing at a live oracle (e.g. a Uniswap v3 TWAP reader) once
/// the token has a real pool — `fuelPerDay()` then tracks it. Set to 0 to fall back to the
/// manual rate (the state during the launchpad bonding curve, before a pool exists).
function setFuelOracle(address oracle) external onlyOwner {
fuelOracle = oracle;
emit FuelOracleSet(oracle);
}
/// @notice Tune the oracle sanity band: the live rate is trusted only within [manual/band, manual*band].
/// Must be >= 1 (band == 1 pins the oracle to exactly the manual rate). Tighten it toward the
/// real volatility once the manual anchor is nudged near the pool peg. Does not affect the ETH
/// life-support path, which never reads the oracle.
function setFuelOracleBand(uint256 band) external onlyOwner {
require(band >= 1, "band < 1");
fuelOracleBand = band;
emit FuelOracleBandSet(band);
}
// ──────────────── Lifecycle cost tuning (owner) ────────────────
/// @notice Min ETH to awaken a Husk (fund-to-reveal; the deposit seeds its upkeep). Floored at the
/// keeper bounty so emerge() can always pay a keeper.
function setRevealDeposit(uint256 v) external onlyOwner {
require(v >= KEEPER_BOUNTY_FLOOR, "below keeper bounty");
revealDeposit = v;
emit LifecycleCostsSet(revealDeposit, dailyLifeSupport, rebornDeposit);
}
/// @notice ETH drawn per day of life support (0 = free upkeep — Husks never starve; not advised,
/// it neuters the death/rebirth loop).
function setDailyLifeSupport(uint256 v) external onlyOwner {
dailyLifeSupport = v;
emit LifecycleCostsSet(revealDeposit, dailyLifeSupport, rebornDeposit);
}
/// @notice Min ETH to revive a starved Husk into a new fighter. Floored at the keeper bounty.
function setRebornDeposit(uint256 v) external onlyOwner {
require(v >= KEEPER_BOUNTY_FLOOR, "below keeper bounty");
rebornDeposit = v;
emit LifecycleCostsSet(revealDeposit, dailyLifeSupport, rebornDeposit);
}
// ──────────────── Mint phase + whitelist admin ────────────────
/// @notice Advance the mint phase: Closed → Allowlist → Public (can be set to any value).
function setMintPhase(Phase phase) external onlyOwner {
mintPhase = phase;
emit MintPhaseSet(phase);
}
/// @notice Set the whitelist Merkle root (leaf = keccak256(abi.encodePacked(minter))).
function setAllowlistRoot(bytes32 root) external onlyOwner {
allowlistRoot = root;
emit AllowlistRootSet(root);
}
/// @notice Add or remove wallets on the DIRECT on-chain allowlist (batched). Owner-only, driven from
/// the admin panel — no merkle root rebuild needed to manage a small list live.
function setAllowlist(address[] calldata accounts, bool status) external onlyOwner {
for (uint256 i = 0; i < accounts.length; i++) {
allowlisted[accounts[i]] = status;
emit AllowlistSet(accounts[i], status);
}
}
/// @notice Set the allowlist-phase price (may be a discount on, or equal to, the public price).
function setAllowlistPrice(uint256 price) external onlyOwner {
allowlistPrice = price;
emit AllowlistPriceSet(price);
}
// ──────────────── ERC-4906: dynamic-art metadata refresh ────────────────
/// @notice Signal marketplaces to re-pull a token's metadata after its art changed. Called by
/// the token's OWN account (on reveal/level-up) and the arena (after a fight). Reverts
/// are swallowed by callers so a refresh can never brick a core action.
function notifyMetadataUpdate(uint256 tokenId) external {
require(
huskAccounts[tokenId] == msg.sender || msg.sender == arenaContract,
"Only token account or arena"
);
emit MetadataUpdate(tokenId);
}
/// @notice Permissionless retroactive refresh — anyone (a keeper, the dApp) can poke a token
/// or a range so a marketplace re-pulls the current on-chain art. Purely a hint.
function refreshMetadata(uint256 tokenId) external {
if (huskAccounts[tokenId] == address(0)) revert TokenDoesNotExist();
emit MetadataUpdate(tokenId);
}
function refreshMetadataRange(uint256 fromTokenId, uint256 toTokenId) external {
require(toTokenId < totalMinted && fromTokenId <= toTokenId, "bad range");
emit BatchMetadataUpdate(fromTokenId, toTokenId);
}
// ──────────────── Collection metadata ────────────────
/// @notice Collection-level metadata for marketplace collection pages. If unset, returns an
/// on-chain default carrying the ERC-2981 royalty (bps + recipient) for legacy readers.
function contractURI() external view returns (string memory) {
if (bytes(_contractURI).length != 0) return _contractURI;
(address recv, uint256 bps) = royaltyInfo(0, 10000);
bytes memory json = abi.encodePacked(
'{"name":"Husks","description":"Fully on-chain AI fighters that live in their own wallets. Born in a coffin, revealed on funding, scarred by battle.",',
'"seller_fee_basis_points":', bps.toString(),
',"fee_recipient":"', Strings.toHexString(recv), '"}'
);
return string(abi.encodePacked("data:application/json;base64,", Base64.encode(json)));
}
function setContractURI(string calldata uri) external onlyOwner {
_contractURI = uri;
emit ContractURISet();
}
function setMintPrice(uint256 _price) external onlyOwner {
mintPrice = _price;
}
/// @notice Point the free-mint tier at the $HUSK token. Set to address(0) to disable the free tier.
function setHuskToken(address token) external onlyOwner {
huskToken = token;
emit FreeMintConfigSet(token, freeMintThreshold);
}
/// @notice Minimum $HUSK balance (base units, 18 decimals) that earns a free mint. 0 disables it.
function setFreeMintThreshold(uint256 amount) external onlyOwner {
freeMintThreshold = amount;
emit FreeMintConfigSet(huskToken, amount);
}
/// @notice Raise the mint cap. INCREASE-ONLY (can never shrink the collection or drop below what's
/// already minted) and bounded by the immutable MAX_SUPPLY_CEILING (which sits far below the
/// mercenary id space at 1e9), so the collection can grow if the game needs more supply while
/// holders keep a hard, known ceiling on dilution. Every change is event-logged for transparency.
function setMaxSupply(uint256 newMax) external onlyOwner {
require(newMax > maxSupply, "increase only"); // never shrink (also keeps newMax > totalMinted)
require(newMax <= MAX_SUPPLY_CEILING, "above ceiling");
uint256 old = maxSupply;
maxSupply = newMax;
emit MaxSupplyIncreased(old, newMax);
}
/// @notice Migrate the treasury. Moves the mint-fee/life-support destination AND re-points the
/// ERC-2981 royalty receiver to match (keeping the current bps), so secondary royalties
/// never keep flowing to an abandoned address after a migration.
function setTreasury(address _treasury) external onlyOwner {
require(_treasury != address(0), "Zero address"); // don't burn future fees
treasury = _treasury;
(, uint256 bps) = royaltyInfo(0, 10000); // current rate in bps (denominator 10000)
_setDefaultRoyalty(_treasury, uint96(bps));
}
/// @notice Adjust the ERC-2981 secondary royalty rate (receiver stays the treasury).
/// Hard-capped at 10% so holders can never have the royalty rugged upward.
function setRoyalty(uint96 bps) external onlyOwner {
require(bps <= MAX_ROYALTY_BPS, "Royalty above hard cap");
_setDefaultRoyalty(treasury, bps);
}
/// @dev ERC721 + ERC2981 + ERC-4906. IERC4906 has no functions, so its interfaceId is the
/// literal 0x49064906 (NOT type(IERC4906).interfaceId, which would compute to 0).
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC2981, IERC165)
returns (bool)
{
return interfaceId == bytes4(0x49064906) || super.supportsInterface(interfaceId);
}
// ──────────────────── View Helpers ────────────────────
function getHuskAccount(uint256 tokenId) external view returns (address) {
return huskAccounts[tokenId];
}
/// @notice All token ids owned by `owner`. O(totalMinted) — a free view call, not a tx. Lets a
/// frontend show "my Husks" without ERC721Enumerable's per-transfer gas cost.
function tokensOfOwner(address owner) external view returns (uint256[] memory ids) {
uint256 bal = balanceOf(owner);
ids = new uint256[](bal);
uint256 found;
for (uint256 t = 0; t < totalMinted && found < bal; t++) {
if (_ownerOf(t) == owner) ids[found++] = t;
}
}
/// @notice Everything a UI needs to render one Husk card, in a single call (owner, its wallet,
/// reveal/live state, class, level, record, and its wallet's ETH — the "life force").
struct HuskView {
uint256 tokenId;
address account; // the token-bound wallet
address owner;
bool revealed; // isActive — coffin opened?
bool pendingReveal; // awaken/reborn committed, awaiting emerge
uint16 generation; // 0 = original, 1+ = reborn
uint8 level;
uint32 wins;
uint32 losses;
uint16 currentStreak;
uint256 accountBalance; // ETH held by the Husk's wallet (upkeep runway)
string tokenURI; // full on-chain metadata (coffin while dormant, character when live)
}
function getHusk(uint256 tokenId) external view returns (HuskView memory v) {
address acctAddr = huskAccounts[tokenId];
require(acctAddr != address(0), "TokenDoesNotExist");
HuskAccount a = HuskAccount(payable(acctAddr));
v.tokenId = tokenId;
v.account = acctAddr;
v.owner = ownerOf(tokenId);
v.revealed = a.isActive();
v.pendingReveal = a.revealCommitBlock() != 0;
v.generation = a.generation();
v.level = a.level();
v.wins = a.winCount();
v.losses = a.lossCount();
v.currentStreak = a.currentStreak();
v.accountBalance = acctAddr.balance;
v.tokenURI = tokenURI(tokenId);
}
/// @notice Batched getHusk for a list of ids (one RPC round-trip for a whole wallet/gallery).
function getHusks(uint256[] calldata tokenIds) external view returns (HuskView[] memory out) {
out = new HuskView[](tokenIds.length);
for (uint256 i = 0; i < tokenIds.length; i++) out[i] = this.getHusk(tokenIds[i]);
}
}
Chain explorer3184msChain node107ms