From 17be700bc43946abb6ff97876248898700e2c3f7 Mon Sep 17 00:00:00 2001 From: loothero Date: Fri, 24 Jul 2026 20:55:28 -0700 Subject: [PATCH 1/6] Add permissionless BeastRegistry and StoredArtProvider factory PR 3 of the Community Beasts plan (docs/community-beasts-design.md): the permissionless surface. Anyone can register a new Beast species in one transaction; the NFT integration (mint auth, provenance mint, token_uri routing) lands next. - BeastRegistry: sequential u64 species IDs from 76; two registration paths (factory-deployed StoredArtProvider with salt = beast_id, or a custom IBeastArtProvider address); per-species artist admin with set_minter (zero = paused), one-way lock_minter and lock_art, update_art / notify_art_updated sharing a per-species refresh cooldown, stats_source setter, and transferable artist role. Tier, type, and flags pack into one SpeciesMeta slot. Registration reverts until the owner wires set_nft_address (one-time). - On-chain name guard: charset [A-Za-z0-9 ' -], no edge spaces, non-empty, <= 31 bytes - an injection defense for the unescaped JSON/SVG builders. Name uniqueness deliberately NOT enforced (squatting grief vector); species ID is the identity. - set_art_provider recomputes the factory flag against the species' canonical factory deploy, so update_art can never write through another species' provider; StoredArtProvider.set_art double-gates on registry caller + species ID match. - IBeastArtProvider takes the full PackableBeast so providers select variants themselves and can customize by prefix/suffix/tier. - New IBeastsProvenance interface (mint_provenance, emit_species_metadata_update) that beasts_nft implements in the next PR; covered here by a recording mock. - 39 new tests: meta packing, name-guard injection payloads, both registration paths, admin auth, both locks, cooldown sharing, factory-flag recompute, provider gating, artist transfer. Co-Authored-By: Claude Fable 5 --- src/beast_registry.cairo | 650 +++++++++++++++++++++++++++++++++ src/beast_registry_tests.cairo | 580 +++++++++++++++++++++++++++++ src/interfaces.cairo | 134 +++++++ src/lib.cairo | 4 + src/stored_art_provider.cairo | 93 +++++ 5 files changed, 1461 insertions(+) create mode 100644 src/beast_registry.cairo create mode 100644 src/beast_registry_tests.cairo create mode 100644 src/stored_art_provider.cairo diff --git a/src/beast_registry.cairo b/src/beast_registry.cairo new file mode 100644 index 0000000..457fb57 --- /dev/null +++ b/src/beast_registry.cairo @@ -0,0 +1,650 @@ +/// Permissionless registry for community Beast species. +/// +/// Anyone can register a new species (art, name, type, tier, minter) in a +/// single transaction. The registry is the only permissionless surface of the +/// system: the Beasts NFT contract reads species data and minter auth from +/// here, and exposes registry-only entrypoints for the provenance mint and +/// metadata-refresh fan-out (`IBeastsProvenance`). +/// +/// Species IDs are assigned sequentially starting at 76 (1-75 are the genesis +/// species defined in `beast_definitions`). IDs are `u64`, so the collection +/// can never be filled or squatted out. +#[starknet::contract] +pub mod beast_registry { + use core::num::traits::Zero; + use openzeppelin_access::ownable::OwnableComponent; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use starknet::{ClassHash, ContractAddress}; + use super::super::interfaces::{ + BeastDefinition, BeastType, IBeastRegistry, IBeastsProvenanceDispatcher, + IBeastsProvenanceDispatcherTrait, IStoredArtProviderDispatcher, + IStoredArtProviderDispatcherTrait, + }; + use super::{SpeciesMeta, assert_valid_name}; + + /// The first community species ID; 1-75 are genesis species. + pub const FIRST_COMMUNITY_ID: u64 = 76; + + /// Shared per-species cooldown for art-driven metadata refreshes + /// (`update_art` and `notify_art_updated`), protecting indexers from + /// event-spam. Tune before deployment if needed. + pub const ART_REFRESH_COOLDOWN_SECONDS: u64 = 3600; + + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + #[abi(embed_v0)] + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + ownable: OwnableComponent::Storage, + // Per-species definition. `meta` packs tier/type/flags into one slot. + names: Map, + artists: Map, + minters: Map, + art_providers: Map, + factory_providers: Map, // canonical factory deploy, 0 if none + stats_sources: Map, + metas: Map, + last_art_refresh: Map, + next_id: u64, + stored_art_class_hash: ClassHash, + nft: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct BeastRegistered { + #[key] + pub beast_id: u64, + pub name: felt252, + pub artist: ContractAddress, + pub minter: ContractAddress, + pub tier: u8, + pub beast_type: u8, + pub art_provider: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct MinterUpdated { + #[key] + pub beast_id: u64, + pub minter: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct MinterLocked { + #[key] + pub beast_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ArtUpdated { + #[key] + pub beast_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct ArtProviderUpdated { + #[key] + pub beast_id: u64, + pub art_provider: ContractAddress, + pub factory_provider: bool, + } + + #[derive(Drop, starknet::Event)] + pub struct ArtLocked { + #[key] + pub beast_id: u64, + } + + #[derive(Drop, starknet::Event)] + pub struct StatsSourceUpdated { + #[key] + pub beast_id: u64, + pub stats_source: ContractAddress, + } + + #[derive(Drop, starknet::Event)] + pub struct ArtistTransferred { + #[key] + pub beast_id: u64, + pub previous_artist: ContractAddress, + pub new_artist: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + OwnableEvent: OwnableComponent::Event, + BeastRegistered: BeastRegistered, + MinterUpdated: MinterUpdated, + MinterLocked: MinterLocked, + ArtUpdated: ArtUpdated, + ArtProviderUpdated: ArtProviderUpdated, + ArtLocked: ArtLocked, + StatsSourceUpdated: StatsSourceUpdated, + ArtistTransferred: ArtistTransferred, + } + + #[constructor] + fn constructor( + ref self: ContractState, owner: ContractAddress, stored_art_class_hash: ClassHash, + ) { + self.ownable.initializer(owner); + self.stored_art_class_hash.write(stored_art_class_hash); + self.next_id.write(FIRST_COMMUNITY_ID); + } + + #[abi(embed_v0)] + impl BeastRegistryImpl of IBeastRegistry { + fn register_beast_with_art( + ref self: ContractState, + name: felt252, + beast_type: BeastType, + tier: u8, + minter: ContractAddress, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ) -> u64 { + let beast_id = InternalTrait::assert_registration_valid(@self, name, tier); + + // Deploy the canonical StoredArtProvider for this species. + // salt = beast_id makes the address deterministic per species. + let mut calldata: Array = array![]; + starknet::get_contract_address().serialize(ref calldata); + beast_id.serialize(ref calldata); + png_regular.serialize(ref calldata); + png_shiny.serialize(ref calldata); + gif_regular.serialize(ref calldata); + gif_shiny.serialize(ref calldata); + + let (provider, _) = starknet::syscalls::deploy_syscall( + self.stored_art_class_hash.read(), beast_id.into(), calldata.span(), false, + ) + .expect('Registry: art deploy failed'); + + self.factory_providers.entry(beast_id).write(provider); + InternalTrait::store_and_mint( + ref self, beast_id, name, beast_type, tier, minter, provider, true, + ) + } + + fn register_beast( + ref self: ContractState, + name: felt252, + beast_type: BeastType, + tier: u8, + minter: ContractAddress, + art_provider: ContractAddress, + ) -> u64 { + let beast_id = InternalTrait::assert_registration_valid(@self, name, tier); + assert(art_provider.is_non_zero(), 'Registry: zero art provider'); + + InternalTrait::store_and_mint( + ref self, beast_id, name, beast_type, tier, minter, art_provider, false, + ) + } + + fn set_minter(ref self: ContractState, beast_id: u64, minter: ContractAddress) { + InternalTrait::assert_only_artist(@self, beast_id); + let meta = self.metas.entry(beast_id).read(); + assert(!meta.minter_locked, 'Registry: minter locked'); + + self.minters.entry(beast_id).write(minter); + self.emit(MinterUpdated { beast_id, minter }); + } + + fn lock_minter(ref self: ContractState, beast_id: u64) { + InternalTrait::assert_only_artist(@self, beast_id); + let mut meta = self.metas.entry(beast_id).read(); + assert(!meta.minter_locked, 'Registry: minter locked'); + + meta.minter_locked = true; + self.metas.entry(beast_id).write(meta); + self.emit(MinterLocked { beast_id }); + } + + fn update_art( + ref self: ContractState, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ) { + InternalTrait::assert_only_artist(@self, beast_id); + let meta = self.metas.entry(beast_id).read(); + assert(!meta.art_locked, 'Registry: art locked'); + assert(meta.factory_provider, 'Registry: not factory provider'); + InternalTrait::assert_refresh_cooldown(ref self, beast_id); + + let provider = IStoredArtProviderDispatcher { + contract_address: self.art_providers.entry(beast_id).read(), + }; + provider.set_art(beast_id, png_regular, png_shiny, gif_regular, gif_shiny); + + self.emit(ArtUpdated { beast_id }); + InternalTrait::notify_nft_art_updated(ref self, beast_id); + } + + fn set_art_provider(ref self: ContractState, beast_id: u64, provider: ContractAddress) { + InternalTrait::assert_only_artist(@self, beast_id); + let mut meta = self.metas.entry(beast_id).read(); + assert(!meta.art_locked, 'Registry: art locked'); + assert(provider.is_non_zero(), 'Registry: zero art provider'); + + // The factory flag is recomputed against the species' canonical + // factory deploy on every swap; it can never be true while + // pointing at another species' provider. + meta.factory_provider = provider == self.factory_providers.entry(beast_id).read(); + self.metas.entry(beast_id).write(meta); + self.art_providers.entry(beast_id).write(provider); + + self + .emit( + ArtProviderUpdated { + beast_id, art_provider: provider, factory_provider: meta.factory_provider, + }, + ); + } + + fn notify_art_updated(ref self: ContractState, beast_id: u64) { + InternalTrait::assert_only_artist(@self, beast_id); + let meta = self.metas.entry(beast_id).read(); + assert(!meta.art_locked, 'Registry: art locked'); + InternalTrait::assert_refresh_cooldown(ref self, beast_id); + + self.emit(ArtUpdated { beast_id }); + InternalTrait::notify_nft_art_updated(ref self, beast_id); + } + + fn lock_art(ref self: ContractState, beast_id: u64) { + InternalTrait::assert_only_artist(@self, beast_id); + let mut meta = self.metas.entry(beast_id).read(); + assert(!meta.art_locked, 'Registry: art locked'); + + meta.art_locked = true; + self.metas.entry(beast_id).write(meta); + self.emit(ArtLocked { beast_id }); + } + + fn set_stats_source(ref self: ContractState, beast_id: u64, source: ContractAddress) { + InternalTrait::assert_only_artist(@self, beast_id); + + self.stats_sources.entry(beast_id).write(source); + self.emit(StatsSourceUpdated { beast_id, stats_source: source }); + } + + fn transfer_artist_role( + ref self: ContractState, beast_id: u64, new_artist: ContractAddress, + ) { + InternalTrait::assert_only_artist(@self, beast_id); + assert(new_artist.is_non_zero(), 'Registry: zero artist'); + + let previous_artist = self.artists.entry(beast_id).read(); + self.artists.entry(beast_id).write(new_artist); + self.emit(ArtistTransferred { beast_id, previous_artist, new_artist }); + } + + fn get_definition(self: @ContractState, beast_id: u64) -> BeastDefinition { + InternalTrait::assert_registered(self, beast_id); + let meta = self.metas.entry(beast_id).read(); + + BeastDefinition { + name: self.names.entry(beast_id).read(), + beast_type: meta.beast_type, + tier: meta.tier, + minter: self.minters.entry(beast_id).read(), + artist: self.artists.entry(beast_id).read(), + art_provider: self.art_providers.entry(beast_id).read(), + stats_source: self.stats_sources.entry(beast_id).read(), + factory_provider: meta.factory_provider, + art_locked: meta.art_locked, + minter_locked: meta.minter_locked, + } + } + + fn get_minter(self: @ContractState, beast_id: u64) -> ContractAddress { + self.minters.entry(beast_id).read() + } + + fn get_artist(self: @ContractState, beast_id: u64) -> ContractAddress { + self.artists.entry(beast_id).read() + } + + fn get_art_provider(self: @ContractState, beast_id: u64) -> ContractAddress { + self.art_providers.entry(beast_id).read() + } + + fn get_stats_source(self: @ContractState, beast_id: u64) -> ContractAddress { + self.stats_sources.entry(beast_id).read() + } + + fn get_species_traits(self: @ContractState, beast_id: u64) -> (u8, u8) { + InternalTrait::assert_registered(self, beast_id); + let meta = self.metas.entry(beast_id).read(); + (meta.tier, meta.beast_type) + } + + fn get_species_name(self: @ContractState, beast_id: u64) -> felt252 { + InternalTrait::assert_registered(self, beast_id); + self.names.entry(beast_id).read() + } + + fn is_registered(self: @ContractState, beast_id: u64) -> bool { + beast_id >= FIRST_COMMUNITY_ID && beast_id < self.next_id.read() + } + + fn is_art_locked(self: @ContractState, beast_id: u64) -> bool { + self.metas.entry(beast_id).read().art_locked + } + + fn is_minter_locked(self: @ContractState, beast_id: u64) -> bool { + self.metas.entry(beast_id).read().minter_locked + } + + fn species_count(self: @ContractState) -> u64 { + // Total species including the 75 genesis species. + self.next_id.read() - 1 + } + + fn get_nft_address(self: @ContractState) -> ContractAddress { + self.nft.read() + } + + fn get_stored_art_class_hash(self: @ContractState) -> ClassHash { + self.stored_art_class_hash.read() + } + + fn set_nft_address(ref self: ContractState, nft: ContractAddress) { + self.ownable.assert_only_owner(); + assert(self.nft.read().is_zero(), 'Registry: nft already set'); + assert(nft.is_non_zero(), 'Registry: zero nft'); + self.nft.write(nft); + } + + fn set_stored_art_class_hash(ref self: ContractState, class_hash: ClassHash) { + self.ownable.assert_only_owner(); + self.stored_art_class_hash.write(class_hash); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + /// Common registration validation. Returns the species ID that will + /// be assigned. Reverts until `set_nft_address` has been called so a + /// half-wired deployment cannot accept registrations. + fn assert_registration_valid(self: @ContractState, name: felt252, tier: u8) -> u64 { + assert(self.nft.read().is_non_zero(), 'Registry: nft not set'); + assert(tier >= 1 && tier <= 5, 'Registry: invalid tier'); + assert_valid_name(name); + self.next_id.read() + } + + /// Writes the full definition, advances the ID counter, then calls + /// the NFT for the provenance mint. Definition is stored BEFORE the + /// external call: the NFT reads tier/type back from the registry to + /// encode the Genesis Beast's token ID. + fn store_and_mint( + ref self: ContractState, + beast_id: u64, + name: felt252, + beast_type: BeastType, + tier: u8, + minter: ContractAddress, + art_provider: ContractAddress, + factory_provider: bool, + ) -> u64 { + let artist = starknet::get_caller_address(); + let type_code: u8 = beast_type.into(); + + self.names.entry(beast_id).write(name); + self.artists.entry(beast_id).write(artist); + self.minters.entry(beast_id).write(minter); + self.art_providers.entry(beast_id).write(art_provider); + self + .metas + .entry(beast_id) + .write( + SpeciesMeta { + tier, + beast_type: type_code, + factory_provider, + art_locked: false, + minter_locked: false, + }, + ); + self.next_id.write(beast_id + 1); + + let nft = IBeastsProvenanceDispatcher { contract_address: self.nft.read() }; + nft.mint_provenance(artist, beast_id); + + self + .emit( + BeastRegistered { + beast_id, name, artist, minter, tier, beast_type: type_code, art_provider, + }, + ); + + beast_id + } + + fn assert_registered(self: @ContractState, beast_id: u64) { + assert( + beast_id >= FIRST_COMMUNITY_ID && beast_id < self.next_id.read(), + 'Registry: not registered', + ); + } + + fn assert_only_artist(self: @ContractState, beast_id: u64) { + Self::assert_registered(self, beast_id); + let caller = starknet::get_caller_address(); + assert(caller == self.artists.entry(beast_id).read(), 'Registry: not artist'); + } + + fn assert_refresh_cooldown(ref self: ContractState, beast_id: u64) { + let now = starknet::get_block_timestamp(); + let last = self.last_art_refresh.entry(beast_id).read(); + assert( + last == 0 || now >= last + ART_REFRESH_COOLDOWN_SECONDS, + 'Registry: refresh cooldown', + ); + self.last_art_refresh.entry(beast_id).write(now); + } + + fn notify_nft_art_updated(ref self: ContractState, beast_id: u64) { + let nft = IBeastsProvenanceDispatcher { contract_address: self.nft.read() }; + nft.emit_species_metadata_update(beast_id); + } + } +} + +/// Per-species static traits and flags, manually packed into one felt252 +/// storage slot: tier (8 bits) | type (8 bits) | factory (1) | art_locked (1) +/// | minter_locked (1). +#[derive(Drop, Copy, Serde, PartialEq)] +pub struct SpeciesMeta { + pub tier: u8, + pub beast_type: u8, + pub factory_provider: bool, + pub art_locked: bool, + pub minter_locked: bool, +} + +const TWO_POW_8: u256 = 0x100; +const TWO_POW_16: u256 = 0x10000; +const TWO_POW_17: u256 = 0x20000; +const TWO_POW_18: u256 = 0x40000; + +pub impl SpeciesMetaStorePacking of starknet::storage_access::StorePacking { + fn pack(value: SpeciesMeta) -> felt252 { + let packed: u256 = value.tier.into() + + value.beast_type.into() * TWO_POW_8 + + if value.factory_provider { + TWO_POW_16 + } else { + 0 + } + + if value.art_locked { + TWO_POW_17 + } else { + 0 + } + + if value.minter_locked { + TWO_POW_18 + } else { + 0 + }; + packed.try_into().expect('pack species meta') + } + + fn unpack(value: felt252) -> SpeciesMeta { + let mut packed: u256 = value.into(); + + let tier: u8 = (packed % TWO_POW_8).try_into().expect('unpack tier'); + packed = packed / TWO_POW_8; + let beast_type: u8 = (packed % TWO_POW_8).try_into().expect('unpack type'); + packed = packed / TWO_POW_8; + let factory_provider = (packed % 2) == 1; + packed = packed / 2; + let art_locked = (packed % 2) == 1; + packed = packed / 2; + let minter_locked = (packed % 2) == 1; + packed = packed / 2; + + assert(packed == 0, 'invalid species meta'); + + SpeciesMeta { tier, beast_type, factory_provider, art_locked, minter_locked } + } +} + +/// On-chain name guard. This is an injection defense, not a style rule: +/// `components_to_json` and the SVG builder embed species names unescaped, +/// so the charset is what keeps every token's metadata well-formed. +/// +/// Rules: non-empty, <= 31 bytes, characters restricted to +/// [A-Za-z0-9], space, apostrophe, hyphen; no leading or trailing space. +/// Uniqueness is deliberately NOT enforced (name-squatting grief vector); +/// species ID is the identity. +pub fn assert_valid_name(name: felt252) { + assert(name != 0, 'Registry: empty name'); + + let mut value: u256 = name.into(); + let mut len: u32 = 0; + // Bytes are extracted low-to-high, i.e. last character first. + let mut leading_byte: u8 = 0; + let mut trailing_byte: u8 = 0; + + while value != 0 { + let byte: u8 = (value % 0x100).try_into().unwrap(); + assert(is_allowed_name_char(byte), 'Registry: invalid name char'); + if len == 0 { + trailing_byte = byte; + } + leading_byte = byte; + len += 1; + value = value / 0x100; + } + + assert(len <= 31, 'Registry: name too long'); + assert(leading_byte != 0x20, 'Registry: leading space'); + assert(trailing_byte != 0x20, 'Registry: trailing space'); +} + +fn is_allowed_name_char(byte: u8) -> bool { + (byte >= 'A' && byte <= 'Z') + || (byte >= 'a' && byte <= 'z') + || (byte >= '0' && byte <= '9') + || byte == ' ' + || byte == '\'' + || byte == '-' +} + +#[cfg(test)] +mod tests { + use super::{SpeciesMeta, SpeciesMetaStorePacking, assert_valid_name}; + + #[test] + fn test_species_meta_round_trip() { + let meta = SpeciesMeta { + tier: 3, beast_type: 2, factory_provider: true, art_locked: false, minter_locked: true, + }; + let unpacked = SpeciesMetaStorePacking::unpack(SpeciesMetaStorePacking::pack(meta)); + assert(unpacked == meta, 'meta round trip'); + } + + #[test] + fn test_species_meta_all_flags() { + let meta = SpeciesMeta { + tier: 5, beast_type: 0, factory_provider: true, art_locked: true, minter_locked: true, + }; + let unpacked = SpeciesMetaStorePacking::unpack(SpeciesMetaStorePacking::pack(meta)); + assert(unpacked == meta, 'all flags round trip'); + } + + #[test] + fn test_valid_names() { + assert_valid_name('Warlock'); + assert_valid_name('Fire Drake'); + assert_valid_name('K9'); + assert_valid_name('Ol\' One-Eye'); + assert_valid_name('x'); + assert_valid_name('A name that is 31 bytes long ok'); + } + + #[test] + #[should_panic(expected: 'Registry: empty name')] + fn test_empty_name_rejected() { + assert_valid_name(0); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_double_quote_rejected() { + assert_valid_name('bad"name'); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_backslash_rejected() { + assert_valid_name('bad\\name'); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_angle_bracket_rejected() { + assert_valid_name(''); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_control_byte_rejected() { + // 0x07 (BEL) embedded via a raw felt value: 'A' << 8 | 0x07 + assert_valid_name(0x4107); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_comma_rejected() { + assert_valid_name('a,b'); + } + + #[test] + #[should_panic(expected: 'Registry: leading space')] + fn test_leading_space_rejected() { + assert_valid_name(' Warlock'); + } + + #[test] + #[should_panic(expected: 'Registry: trailing space')] + fn test_trailing_space_rejected() { + assert_valid_name('Warlock '); + } +} diff --git a/src/beast_registry_tests.cairo b/src/beast_registry_tests.cairo new file mode 100644 index 0000000..7e13c93 --- /dev/null +++ b/src/beast_registry_tests.cairo @@ -0,0 +1,580 @@ +/// Minimal mock of the Beasts NFT registry-facing surface. Records the last +/// provenance mint and fan-out call so tests can verify the registry drives +/// the NFT correctly (the real implementation lands with the NFT +/// integration). +#[starknet::contract] +pub mod mock_beasts_nft { + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use super::super::interfaces::IBeastsProvenance; + + #[starknet::interface] + pub trait IMockCounters { + fn last_provenance(self: @TContractState) -> (ContractAddress, u64); + fn provenance_count(self: @TContractState) -> u64; + fn last_fan_out(self: @TContractState) -> u64; + fn fan_out_count(self: @TContractState) -> u64; + } + + #[storage] + struct Storage { + last_provenance_artist: ContractAddress, + last_provenance_id: u64, + provenance_count: u64, + last_fan_out_id: u64, + fan_out_count: u64, + } + + #[abi(embed_v0)] + impl ProvenanceImpl of IBeastsProvenance { + fn mint_provenance(ref self: ContractState, artist: ContractAddress, beast_id: u64) { + self.last_provenance_artist.write(artist); + self.last_provenance_id.write(beast_id); + self.provenance_count.write(self.provenance_count.read() + 1); + } + + fn emit_species_metadata_update(ref self: ContractState, beast_id: u64) { + self.last_fan_out_id.write(beast_id); + self.fan_out_count.write(self.fan_out_count.read() + 1); + } + } + + #[abi(embed_v0)] + impl MockCountersImpl of IMockCounters { + fn last_provenance(self: @ContractState) -> (ContractAddress, u64) { + (self.last_provenance_artist.read(), self.last_provenance_id.read()) + } + + fn provenance_count(self: @ContractState) -> u64 { + self.provenance_count.read() + } + + fn last_fan_out(self: @ContractState) -> u64 { + self.last_fan_out_id.read() + } + + fn fan_out_count(self: @ContractState) -> u64 { + self.fan_out_count.read() + } + } +} + +#[cfg(test)] +mod tests { + use beasts_nft::beast_registry::beast_registry::ART_REFRESH_COOLDOWN_SECONDS; + use beasts_nft::interfaces::{ + BeastType, IBeastArtProviderDispatcher, IBeastArtProviderDispatcherTrait, + IBeastRegistryDispatcher, IBeastRegistryDispatcherTrait, IStoredArtProviderDispatcher, + IStoredArtProviderDispatcherTrait, + }; + use beasts_nft::pack::PackableBeast; + use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, start_cheat_block_timestamp, + start_cheat_caller_address, stop_cheat_caller_address, + }; + use starknet::ContractAddress; + use super::mock_beasts_nft::{IMockCountersDispatcher, IMockCountersDispatcherTrait}; + + fn test_address(address: felt252) -> ContractAddress { + address.try_into().unwrap() + } + + fn sample_art() -> (ByteArray, ByteArray, ByteArray, ByteArray) { + ( + "data:image/png;base64,PNGREG", + "data:image/png;base64,PNGSHINY", + "data:image/gif;base64,GIFREG", + "data:image/gif;base64,GIFSHINY", + ) + } + + fn community_beast(beast_id: u64, shiny: u8, animated: u8) -> PackableBeast { + PackableBeast { + id: beast_id, + prefix: 1, + suffix: 1, + level: 10, + health: 100, + shiny, + animated, + tier: 3, + beast_type: 1, + } + } + + /// Deploys registry + mock NFT, wires them, returns dispatchers. + fn setup() -> (IBeastRegistryDispatcher, IMockCountersDispatcher, ContractAddress) { + let owner = test_address('owner'); + + let provider_class = declare("stored_art_provider").unwrap().contract_class(); + let registry_class = declare("beast_registry").unwrap().contract_class(); + let mock_nft_class = declare("mock_beasts_nft").unwrap().contract_class(); + + let mut registry_calldata: Array = array![]; + owner.serialize(ref registry_calldata); + provider_class.class_hash.serialize(ref registry_calldata); + let (registry_address, _) = registry_class.deploy(@registry_calldata).unwrap(); + + let (nft_address, _) = mock_nft_class.deploy(@array![]).unwrap(); + + let registry = IBeastRegistryDispatcher { contract_address: registry_address }; + + start_cheat_caller_address(registry_address, owner); + registry.set_nft_address(nft_address); + stop_cheat_caller_address(registry_address); + + (registry, IMockCountersDispatcher { contract_address: nft_address }, owner) + } + + fn register_default( + registry: IBeastRegistryDispatcher, artist: ContractAddress, minter: ContractAddress, + ) -> u64 { + let (png_regular, png_shiny, gif_regular, gif_shiny) = sample_art(); + start_cheat_caller_address(registry.contract_address, artist); + let beast_id = registry + .register_beast_with_art( + 'Gloomfang', + BeastType::Hunter, + 3, + minter, + png_regular, + png_shiny, + gif_regular, + gif_shiny, + ); + stop_cheat_caller_address(registry.contract_address); + beast_id + } + + // ---------------- registration ---------------- + + #[test] + fn test_register_with_art_full_flow() { + let (registry, nft, _) = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + + let beast_id = register_default(registry, artist, minter); + + assert(beast_id == 76, 'First community id is 76'); + assert(registry.is_registered(76), 'Should be registered'); + assert(!registry.is_registered(77), '77 not yet registered'); + assert(!registry.is_registered(75), 'Genesis not in registry'); + assert(registry.species_count() == 76, '75 genesis + 1 community'); + + let def = registry.get_definition(beast_id); + assert(def.name == 'Gloomfang', 'Name mismatch'); + assert(def.beast_type == 1, 'Type mismatch'); + assert(def.tier == 3, 'Tier mismatch'); + assert(def.minter == minter, 'Minter mismatch'); + assert(def.artist == artist, 'Artist mismatch'); + assert(def.factory_provider, 'Should be factory provider'); + assert(!def.art_locked, 'Art starts unlocked'); + assert(!def.minter_locked, 'Minter starts unlocked'); + + let (tier, beast_type) = registry.get_species_traits(beast_id); + assert(tier == 3 && beast_type == 1, 'Traits mismatch'); + + // Provenance mint executed against the NFT with the right args. + let (prov_artist, prov_id) = nft.last_provenance(); + assert(prov_artist == artist, 'Provenance artist mismatch'); + assert(prov_id == beast_id, 'Provenance id mismatch'); + assert(nft.provenance_count() == 1, 'One provenance mint'); + + // Factory provider wired and serving the right variants. + let provider_addr = registry.get_art_provider(beast_id); + let stored = IStoredArtProviderDispatcher { contract_address: provider_addr }; + assert(stored.get_registry() == registry.contract_address, 'Provider registry'); + assert(stored.get_species_id() == beast_id, 'Provider species'); + + let art = IBeastArtProviderDispatcher { contract_address: provider_addr }; + assert( + art.get_data_uri(community_beast(beast_id, 0, 0)) == "data:image/png;base64,PNGREG", + 'regular png variant', + ); + assert( + art.get_data_uri(community_beast(beast_id, 1, 0)) == "data:image/png;base64,PNGSHINY", + 'shiny png variant', + ); + assert( + art.get_data_uri(community_beast(beast_id, 0, 1)) == "data:image/gif;base64,GIFREG", + 'regular gif variant', + ); + assert( + art.get_data_uri(community_beast(beast_id, 1, 1)) == "data:image/gif;base64,GIFSHINY", + 'shiny gif variant', + ); + } + + #[test] + fn test_sequential_ids_and_duplicate_names_allowed() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + + let first = register_default(registry, artist, minter); + // Same name again: uniqueness is deliberately not enforced. + let second = register_default(registry, test_address('artist2'), minter); + + assert(first == 76 && second == 77, 'Sequential ids'); + assert(registry.get_species_name(76) == registry.get_species_name(77), 'Same name ok'); + } + + #[test] + fn test_register_custom_provider() { + let (registry, nft, _) = setup(); + let artist = test_address('artist'); + let custom_provider = test_address('custom_provider'); + + start_cheat_caller_address(registry.contract_address, artist); + let beast_id = registry + .register_beast( + 'Voidling', BeastType::Magic, 1, test_address('dungeon'), custom_provider, + ); + stop_cheat_caller_address(registry.contract_address); + + let def = registry.get_definition(beast_id); + assert(def.art_provider == custom_provider, 'Provider mismatch'); + assert(!def.factory_provider, 'Not factory provider'); + assert(nft.provenance_count() == 1, 'Provenance minted'); + } + + #[test] + fn test_register_with_zero_minter_is_paused_state() { + let (registry, _, _) = setup(); + let beast_id = register_default(registry, test_address('artist'), 0.try_into().unwrap()); + assert(registry.get_minter(beast_id) == 0.try_into().unwrap(), 'Zero minter = paused'); + } + + #[test] + #[should_panic(expected: 'Registry: zero art provider')] + fn test_register_custom_zero_provider_rejected() { + let (registry, _, _) = setup(); + registry + .register_beast( + 'Voidling', BeastType::Magic, 1, test_address('dungeon'), 0.try_into().unwrap(), + ); + } + + #[test] + #[should_panic(expected: 'Registry: invalid tier')] + fn test_register_tier_zero_rejected() { + let (registry, _, _) = setup(); + registry + .register_beast( + 'Voidling', BeastType::Magic, 0, test_address('dungeon'), test_address('p'), + ); + } + + #[test] + #[should_panic(expected: 'Registry: invalid tier')] + fn test_register_tier_six_rejected() { + let (registry, _, _) = setup(); + registry + .register_beast( + 'Voidling', BeastType::Magic, 6, test_address('dungeon'), test_address('p'), + ); + } + + #[test] + #[should_panic(expected: 'Registry: invalid name char')] + fn test_register_injection_name_rejected() { + let (registry, _, _) = setup(); + registry + .register_beast( + 'x","evil":"1', BeastType::Magic, 1, test_address('dungeon'), test_address('p'), + ); + } + + #[test] + #[should_panic(expected: 'Registry: nft not set')] + fn test_register_before_nft_wired_rejected() { + let owner = test_address('owner'); + let provider_class = declare("stored_art_provider").unwrap().contract_class(); + let registry_class = declare("beast_registry").unwrap().contract_class(); + + let mut calldata: Array = array![]; + owner.serialize(ref calldata); + provider_class.class_hash.serialize(ref calldata); + let (registry_address, _) = registry_class.deploy(@calldata).unwrap(); + let registry = IBeastRegistryDispatcher { contract_address: registry_address }; + + registry + .register_beast( + 'Voidling', BeastType::Magic, 1, test_address('dungeon'), test_address('p'), + ); + } + + #[test] + #[should_panic(expected: 'Registry: nft already set')] + fn test_set_nft_address_is_one_time() { + let (registry, _, owner) = setup(); + start_cheat_caller_address(registry.contract_address, owner); + registry.set_nft_address(test_address('other')); + } + + #[test] + #[should_panic(expected: 'Registry: not registered')] + fn test_get_definition_unregistered_reverts() { + let (registry, _, _) = setup(); + registry.get_definition(76); + } + + #[test] + #[should_panic(expected: 'Registry: not registered')] + fn test_get_species_traits_genesis_id_reverts() { + // Genesis species traits come from beast_definitions, not the registry. + let (registry, _, _) = setup(); + registry.get_species_traits(42); + } + + // ---------------- artist admin ---------------- + + #[test] + fn test_set_minter_and_lock() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.set_minter(beast_id, test_address('new_dungeon')); + assert(registry.get_minter(beast_id) == test_address('new_dungeon'), 'Minter rotated'); + + // Pause via zero minter. + registry.set_minter(beast_id, 0.try_into().unwrap()); + assert(registry.get_minter(beast_id) == 0.try_into().unwrap(), 'Minter paused'); + + registry.set_minter(beast_id, test_address('final_dungeon')); + registry.lock_minter(beast_id); + stop_cheat_caller_address(registry.contract_address); + + assert(registry.is_minter_locked(beast_id), 'Minter locked'); + } + + #[test] + #[should_panic(expected: 'Registry: minter locked')] + fn test_set_minter_after_lock_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.lock_minter(beast_id); + registry.set_minter(beast_id, test_address('new_dungeon')); + } + + #[test] + #[should_panic(expected: 'Registry: not artist')] + fn test_set_minter_not_artist_rejected() { + let (registry, _, _) = setup(); + let beast_id = register_default(registry, test_address('artist'), test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, test_address('rando')); + registry.set_minter(beast_id, test_address('evil_dungeon')); + } + + #[test] + fn test_update_art_and_fan_out() { + let (registry, nft, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + let provider_addr = registry.get_art_provider(beast_id); + + start_cheat_caller_address(registry.contract_address, artist); + registry + .update_art( + beast_id, + "data:image/png;base64,NEW", + "data:image/png;base64,NEWSHINY", + "data:image/gif;base64,NEWGIF", + "data:image/gif;base64,NEWGIFSHINY", + ); + stop_cheat_caller_address(registry.contract_address); + + let art = IBeastArtProviderDispatcher { contract_address: provider_addr }; + assert( + art.get_data_uri(community_beast(beast_id, 0, 0)) == "data:image/png;base64,NEW", + 'Art updated', + ); + assert(nft.fan_out_count() == 1, 'Fan-out triggered'); + assert(nft.last_fan_out() == beast_id, 'Fan-out species'); + } + + #[test] + #[should_panic(expected: 'Registry: refresh cooldown')] + fn test_art_refresh_cooldown_shared_across_mutators() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_block_timestamp(registry.contract_address, 1000); + start_cheat_caller_address(registry.contract_address, artist); + registry.update_art(beast_id, "data:a", "data:b", "data:c", "data:d"); + // notify shares the same per-species cooldown as update_art. + registry.notify_art_updated(beast_id); + } + + #[test] + fn test_art_refresh_allowed_after_cooldown() { + let (registry, nft, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_block_timestamp(registry.contract_address, 1000); + start_cheat_caller_address(registry.contract_address, artist); + registry.notify_art_updated(beast_id); + + start_cheat_block_timestamp(registry.contract_address, 1000 + ART_REFRESH_COOLDOWN_SECONDS); + registry.notify_art_updated(beast_id); + stop_cheat_caller_address(registry.contract_address); + + assert(nft.fan_out_count() == 2, 'Two fan-outs'); + } + + #[test] + #[should_panic(expected: 'Registry: not factory provider')] + fn test_update_art_custom_provider_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + + start_cheat_caller_address(registry.contract_address, artist); + let beast_id = registry + .register_beast( + 'Voidling', BeastType::Magic, 1, test_address('dungeon'), test_address('custom'), + ); + registry.update_art(beast_id, "a", "b", "c", "d"); + } + + #[test] + fn test_set_art_provider_recomputes_factory_flag() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + let factory_addr = registry.get_art_provider(beast_id); + + start_cheat_caller_address(registry.contract_address, artist); + + // Swap to a custom provider: factory flag drops. + registry.set_art_provider(beast_id, test_address('custom')); + let def = registry.get_definition(beast_id); + assert(def.art_provider == test_address('custom'), 'Swapped to custom'); + assert(!def.factory_provider, 'Factory flag cleared'); + + // Swap back to the canonical factory deploy: flag restored. + registry.set_art_provider(beast_id, factory_addr); + let def = registry.get_definition(beast_id); + assert(def.art_provider == factory_addr, 'Swapped back'); + assert(def.factory_provider, 'Factory flag restored'); + + stop_cheat_caller_address(registry.contract_address); + } + + #[test] + #[should_panic(expected: 'Registry: art locked')] + fn test_lock_art_blocks_update() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.lock_art(beast_id); + registry.update_art(beast_id, "a", "b", "c", "d"); + } + + #[test] + #[should_panic(expected: 'Registry: art locked')] + fn test_lock_art_blocks_provider_swap() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.lock_art(beast_id); + registry.set_art_provider(beast_id, test_address('custom')); + } + + #[test] + fn test_transfer_artist_role() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let new_artist = test_address('new_artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.transfer_artist_role(beast_id, new_artist); + stop_cheat_caller_address(registry.contract_address); + + assert(registry.get_artist(beast_id) == new_artist, 'Artist transferred'); + + // New artist has admin rights. + start_cheat_caller_address(registry.contract_address, new_artist); + registry.set_minter(beast_id, test_address('their_dungeon')); + stop_cheat_caller_address(registry.contract_address); + } + + #[test] + #[should_panic(expected: 'Registry: not artist')] + fn test_old_artist_loses_rights_after_transfer() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.transfer_artist_role(beast_id, test_address('new_artist')); + registry.set_minter(beast_id, test_address('their_dungeon')); + } + + #[test] + #[should_panic(expected: 'Registry: zero artist')] + fn test_transfer_artist_to_zero_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry.transfer_artist_role(beast_id, 0.try_into().unwrap()); + } + + #[test] + fn test_set_stats_source() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + assert(registry.get_stats_source(beast_id) == 0.try_into().unwrap(), 'Stats default off'); + + start_cheat_caller_address(registry.contract_address, artist); + registry.set_stats_source(beast_id, test_address('stats')); + stop_cheat_caller_address(registry.contract_address); + + assert(registry.get_stats_source(beast_id) == test_address('stats'), 'Stats source set'); + } + + // ---------------- stored art provider gating ---------------- + + #[test] + #[should_panic(expected: 'Provider: not registry')] + fn test_provider_set_art_not_registry_rejected() { + let (registry, _, _) = setup(); + let beast_id = register_default(registry, test_address('artist'), test_address('dungeon')); + let provider = IStoredArtProviderDispatcher { + contract_address: registry.get_art_provider(beast_id), + }; + + // Direct call bypassing the registry must fail, even from the artist. + start_cheat_caller_address(provider.contract_address, test_address('artist')); + provider.set_art(beast_id, "a", "b", "c", "d"); + } + + #[test] + #[should_panic(expected: 'Provider: wrong species')] + fn test_provider_rejects_wrong_species_render() { + let (registry, _, _) = setup(); + let beast_id = register_default(registry, test_address('artist'), test_address('dungeon')); + let art = IBeastArtProviderDispatcher { + contract_address: registry.get_art_provider(beast_id), + }; + + art.get_data_uri(community_beast(beast_id + 1, 0, 0)); + } +} diff --git a/src/interfaces.cairo b/src/interfaces.cairo index 7548449..a244079 100644 --- a/src/interfaces.cairo +++ b/src/interfaces.cairo @@ -67,11 +67,145 @@ pub trait IBeasts { } +/// Legacy image data provider interface, keyed by u8 genesis species IDs. +/// Used by the four deployed art data contracts for species 1-75. #[starknet::interface] pub trait IBeastImageDataProvider { fn get_data_uri(self: @TContractState, beast_id: u8) -> ByteArray; } +/// Art provider interface for community species. Receives the full decoded +/// beast so providers can select variants (shiny/animated are in the struct) +/// and customize rendering by prefix, suffix, tier, or level. +#[starknet::interface] +pub trait IBeastArtProvider { + fn get_data_uri(self: @TContractState, beast: PackableBeast) -> ByteArray; +} + +/// Management interface of the canonical factory-deployed art provider. +#[starknet::interface] +pub trait IStoredArtProvider { + fn set_art( + ref self: TContractState, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ); + fn get_registry(self: @TContractState) -> ContractAddress; + fn get_species_id(self: @TContractState) -> u64; +} + +/// Beast type codes as encoded in token IDs: Magic = 0, Hunter = 1, Brute = 2. +#[derive(Drop, Copy, Serde, PartialEq)] +pub enum BeastType { + Magic, + Hunter, + Brute, +} + +pub impl BeastTypeIntoU8 of Into { + fn into(self: BeastType) -> u8 { + match self { + BeastType::Magic => 0, + BeastType::Hunter => 1, + BeastType::Brute => 2, + } + } +} + +/// Full definition of a registered community species. +#[derive(Drop, Serde)] +pub struct BeastDefinition { + pub name: felt252, + pub beast_type: u8, // type code: 0 = Magic, 1 = Hunter, 2 = Brute + pub tier: u8, // 1..=5 + pub minter: ContractAddress, // dungeon allowed to mint this species; 0 = paused + pub artist: ContractAddress, // registrant; per-species admin + pub art_provider: ContractAddress, + pub stats_source: ContractAddress, // 0 = no kill stats + pub factory_provider: bool, + pub art_locked: bool, + pub minter_locked: bool, +} + +/// Permissionless registry for community Beast species. +#[starknet::interface] +pub trait IBeastRegistry { + // -------- permissionless registration -------- + + /// Simple path: the registry deploys the canonical StoredArtProvider + /// holding the four supplied data URIs. One transaction, no contract + /// knowledge needed. + fn register_beast_with_art( + ref self: TContractState, + name: felt252, + beast_type: BeastType, + tier: u8, + minter: ContractAddress, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ) -> u64; + + /// Advanced path: artist supplies their own IBeastArtProvider (non-zero). + fn register_beast( + ref self: TContractState, + name: felt252, + beast_type: BeastType, + tier: u8, + minter: ContractAddress, + art_provider: ContractAddress, + ) -> u64; + + // -------- per-species admin (artist only) -------- + fn set_minter(ref self: TContractState, beast_id: u64, minter: ContractAddress); + fn lock_minter(ref self: TContractState, beast_id: u64); + fn update_art( + ref self: TContractState, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ); + fn set_art_provider(ref self: TContractState, beast_id: u64, provider: ContractAddress); + fn notify_art_updated(ref self: TContractState, beast_id: u64); + fn lock_art(ref self: TContractState, beast_id: u64); + fn set_stats_source(ref self: TContractState, beast_id: u64, source: ContractAddress); + fn transfer_artist_role(ref self: TContractState, beast_id: u64, new_artist: ContractAddress); + + // -------- reads -------- + fn get_definition(self: @TContractState, beast_id: u64) -> BeastDefinition; + fn get_minter(self: @TContractState, beast_id: u64) -> ContractAddress; + fn get_artist(self: @TContractState, beast_id: u64) -> ContractAddress; + fn get_art_provider(self: @TContractState, beast_id: u64) -> ContractAddress; + fn get_stats_source(self: @TContractState, beast_id: u64) -> ContractAddress; + fn get_species_traits(self: @TContractState, beast_id: u64) -> (u8, u8); // (tier, type) + fn get_species_name(self: @TContractState, beast_id: u64) -> felt252; + fn is_registered(self: @TContractState, beast_id: u64) -> bool; + fn is_art_locked(self: @TContractState, beast_id: u64) -> bool; + fn is_minter_locked(self: @TContractState, beast_id: u64) -> bool; + fn species_count(self: @TContractState) -> u64; + fn get_nft_address(self: @TContractState) -> ContractAddress; + fn get_stored_art_class_hash(self: @TContractState) -> starknet::ClassHash; + + // -------- owner levers -------- + fn set_nft_address(ref self: TContractState, nft: ContractAddress); + fn set_stored_art_class_hash(ref self: TContractState, class_hash: starknet::ClassHash); +} + +/// Registry-facing entrypoints implemented by the Beasts NFT contract. +#[starknet::interface] +pub trait IBeastsProvenance { + /// Mints the species' Genesis Beast (id, 0, 0) to the artist. + fn mint_provenance(ref self: TContractState, artist: ContractAddress, beast_id: u64); + /// Fans out MetadataUpdate events for a species after an art change. + fn emit_species_metadata_update(ref self: TContractState, beast_id: u64); +} + #[starknet::interface] pub trait IBeastSystems { fn add_collectable( diff --git a/src/lib.cairo b/src/lib.cairo index a8dcde7..384f43f 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -6,6 +6,9 @@ pub mod beast_manager; pub mod beast_png_regular_data; pub mod beast_png_shiny_data; pub mod beast_ranking; +pub mod beast_registry; +#[cfg(test)] +mod beast_registry_tests; pub mod beast_svg; pub mod encoding; pub mod enumerable; @@ -17,6 +20,7 @@ pub mod metadata_generator; mod mint_tests; pub mod minting_coordinator; pub mod pack; +pub mod stored_art_provider; #[cfg(test)] mod tests; pub mod utils; diff --git a/src/stored_art_provider.cairo b/src/stored_art_provider.cairo new file mode 100644 index 0000000..cfaa881 --- /dev/null +++ b/src/stored_art_provider.cairo @@ -0,0 +1,93 @@ +/// Canonical art provider deployed by the BeastRegistry factory, one instance +/// per community species. Holds the four variant data URIs and selects among +/// them from the decoded beast's shiny/animated flags. Not upgradable; the +/// only mutator is registry-gated `set_art`, so a factory provider whose +/// species is art-locked is provably frozen. +#[starknet::contract] +pub mod stored_art_provider { + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use super::super::interfaces::{IBeastArtProvider, IStoredArtProvider}; + use super::super::pack::PackableBeast; + + #[storage] + struct Storage { + registry: ContractAddress, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + } + + #[constructor] + fn constructor( + ref self: ContractState, + registry: ContractAddress, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ) { + self.registry.write(registry); + self.beast_id.write(beast_id); + self.png_regular.write(png_regular); + self.png_shiny.write(png_shiny); + self.gif_regular.write(gif_regular); + self.gif_shiny.write(gif_shiny); + } + + #[abi(embed_v0)] + impl BeastArtProviderImpl of IBeastArtProvider { + fn get_data_uri(self: @ContractState, beast: PackableBeast) -> ByteArray { + assert(beast.id == self.beast_id.read(), 'Provider: wrong species'); + + if beast.animated == 1 { + if beast.shiny == 1 { + self.gif_shiny.read() + } else { + self.gif_regular.read() + } + } else { + if beast.shiny == 1 { + self.png_shiny.read() + } else { + self.png_regular.read() + } + } + } + } + + #[abi(embed_v0)] + impl StoredArtProviderImpl of IStoredArtProvider { + fn set_art( + ref self: ContractState, + beast_id: u64, + png_regular: ByteArray, + png_shiny: ByteArray, + gif_regular: ByteArray, + gif_shiny: ByteArray, + ) { + // Double gate: only the registry may write, and only for the + // species this provider was deployed for (defense against any + // registry-side routing bug). + let caller = starknet::get_caller_address(); + assert(caller == self.registry.read(), 'Provider: not registry'); + assert(beast_id == self.beast_id.read(), 'Provider: wrong species'); + + self.png_regular.write(png_regular); + self.png_shiny.write(png_shiny); + self.gif_regular.write(gif_regular); + self.gif_shiny.write(gif_shiny); + } + + fn get_registry(self: @ContractState) -> ContractAddress { + self.registry.read() + } + + fn get_species_id(self: @ContractState) -> u64 { + self.beast_id.read() + } + } +} From 518f601a7514bb67fe0bb578438cfb2b4653e64e Mon Sep 17 00:00:00 2001 From: loothero Date: Fri, 24 Jul 2026 21:07:16 -0700 Subject: [PATCH 2/6] Address Codex review: art content validation, swap fan-out, SRC5 stats gate All three MEDIUM findings from the automated Cairo review: - StoredArtProvider now validates every URI in both the constructor and set_art: exact PNG/GIF data-URI prefix per slot plus a non-empty standard-base64 payload. The renderer embeds these verbatim in a single-quoted SVG attribute and factory providers carry the trusted "verified art" designation, so factory-stored content must be provably inert. - set_art_provider now shares the per-species refresh cooldown and fans out MetadataUpdate atomically with the pointer change - a swap changes every token's rendered art, and without the fan-out a subsequent lock_art would leave marketplaces permanently stale. - set_stats_source now performs the design-specified set-time check: a non-zero source must be a deployed contract answering SRC5 for the new IBEAST_STATS_ID (IBeastStats interface defined for the PR 4 cached-stats flow; zero clears without any call). Tests: art payload/prefix/markup rejection, swap cooldown + fan-out, compliant/non-compliant stats sources. 108 passing. Co-Authored-By: Claude Fable 5 --- src/beast_registry.cairo | 17 +++- src/beast_registry_tests.cairo | 169 +++++++++++++++++++++++++++++++-- src/interfaces.cairo | 21 ++++ src/stored_art_provider.cairo | 56 +++++++++++ 4 files changed, 255 insertions(+), 8 deletions(-) diff --git a/src/beast_registry.cairo b/src/beast_registry.cairo index 457fb57..fcd793b 100644 --- a/src/beast_registry.cairo +++ b/src/beast_registry.cairo @@ -13,12 +13,13 @@ pub mod beast_registry { use core::num::traits::Zero; use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ClassHash, ContractAddress}; use super::super::interfaces::{ - BeastDefinition, BeastType, IBeastRegistry, IBeastsProvenanceDispatcher, + BeastDefinition, BeastType, IBEAST_STATS_ID, IBeastRegistry, IBeastsProvenanceDispatcher, IBeastsProvenanceDispatcherTrait, IStoredArtProviderDispatcher, IStoredArtProviderDispatcherTrait, }; @@ -239,6 +240,11 @@ pub mod beast_registry { let mut meta = self.metas.entry(beast_id).read(); assert(!meta.art_locked, 'Registry: art locked'); assert(provider.is_non_zero(), 'Registry: zero art provider'); + // A provider swap changes every existing token's rendered art, so + // it shares the refresh cooldown and fans out atomically with the + // pointer change — otherwise a swap followed by lock_art would + // leave marketplaces permanently stale. + InternalTrait::assert_refresh_cooldown(ref self, beast_id); // The factory flag is recomputed against the species' canonical // factory deploy on every swap; it can never be true while @@ -253,6 +259,7 @@ pub mod beast_registry { beast_id, art_provider: provider, factory_provider: meta.factory_provider, }, ); + InternalTrait::notify_nft_art_updated(ref self, beast_id); } fn notify_art_updated(ref self: ContractState, beast_id: u64) { @@ -278,6 +285,14 @@ pub mod beast_registry { fn set_stats_source(ref self: ContractState, beast_id: u64, source: ContractAddress) { InternalTrait::assert_only_artist(@self, beast_id); + // Verified once, at set time: a non-zero source must be a + // deployed contract registering IBEAST_STATS_ID via SRC5. + // Zero clears the source (stats off). + if source.is_non_zero() { + let src5 = ISRC5Dispatcher { contract_address: source }; + assert(src5.supports_interface(IBEAST_STATS_ID), 'Registry: bad stats source'); + } + self.stats_sources.entry(beast_id).write(source); self.emit(StatsSourceUpdated { beast_id, stats_source: source }); } diff --git a/src/beast_registry_tests.cairo b/src/beast_registry_tests.cairo index 7e13c93..999777e 100644 --- a/src/beast_registry_tests.cairo +++ b/src/beast_registry_tests.cairo @@ -59,6 +59,36 @@ pub mod mock_beasts_nft { } } +/// Mock kill-stats source with a configurable SRC5 answer, for testing the +/// registry's set-time interface verification. +#[starknet::contract] +pub mod mock_stats_source { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use super::super::interfaces::IBEAST_STATS_ID; + + #[starknet::interface] + pub trait ISRC5Like { + fn supports_interface(self: @TContractState, interface_id: felt252) -> bool; + } + + #[storage] + struct Storage { + compliant: bool, + } + + #[constructor] + fn constructor(ref self: ContractState, compliant: bool) { + self.compliant.write(compliant); + } + + #[abi(embed_v0)] + impl SRC5Impl of ISRC5Like { + fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { + interface_id == IBEAST_STATS_ID && self.compliant.read() + } + } +} + #[cfg(test)] mod tests { use beasts_nft::beast_registry::beast_registry::ART_REFRESH_COOLDOWN_SECONDS; @@ -407,9 +437,10 @@ mod tests { let artist = test_address('artist'); let beast_id = register_default(registry, artist, test_address('dungeon')); + let (png_regular, png_shiny, gif_regular, gif_shiny) = sample_art(); start_cheat_block_timestamp(registry.contract_address, 1000); start_cheat_caller_address(registry.contract_address, artist); - registry.update_art(beast_id, "data:a", "data:b", "data:c", "data:d"); + registry.update_art(beast_id, png_regular, png_shiny, gif_regular, gif_shiny); // notify shares the same per-species cooldown as update_art. registry.notify_art_updated(beast_id); } @@ -447,28 +478,48 @@ mod tests { #[test] fn test_set_art_provider_recomputes_factory_flag() { - let (registry, _, _) = setup(); + let (registry, nft, _) = setup(); let artist = test_address('artist'); let beast_id = register_default(registry, artist, test_address('dungeon')); let factory_addr = registry.get_art_provider(beast_id); + start_cheat_block_timestamp(registry.contract_address, 1000); start_cheat_caller_address(registry.contract_address, artist); - // Swap to a custom provider: factory flag drops. + // Swap to a custom provider: factory flag drops, marketplaces are + // refreshed atomically with the pointer change. registry.set_art_provider(beast_id, test_address('custom')); let def = registry.get_definition(beast_id); assert(def.art_provider == test_address('custom'), 'Swapped to custom'); assert(!def.factory_provider, 'Factory flag cleared'); + assert(nft.fan_out_count() == 1, 'Swap fans out'); - // Swap back to the canonical factory deploy: flag restored. + // Swap back to the canonical factory deploy: flag restored. Swaps + // share the art refresh cooldown, so advance past it first. + start_cheat_block_timestamp(registry.contract_address, 1000 + ART_REFRESH_COOLDOWN_SECONDS); registry.set_art_provider(beast_id, factory_addr); let def = registry.get_definition(beast_id); assert(def.art_provider == factory_addr, 'Swapped back'); assert(def.factory_provider, 'Factory flag restored'); + assert(nft.fan_out_count() == 2, 'Second swap fans out'); stop_cheat_caller_address(registry.contract_address); } + #[test] + #[should_panic(expected: 'Registry: refresh cooldown')] + fn test_provider_swap_shares_refresh_cooldown() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_block_timestamp(registry.contract_address, 1000); + start_cheat_caller_address(registry.contract_address, artist); + registry.notify_art_updated(beast_id); + // A provider swap inside the cooldown window is rejected. + registry.set_art_provider(beast_id, test_address('custom')); + } + #[test] #[should_panic(expected: 'Registry: art locked')] fn test_lock_art_blocks_update() { @@ -536,18 +587,122 @@ mod tests { } #[test] - fn test_set_stats_source() { + fn test_set_stats_source_src5_verified() { let (registry, _, _) = setup(); let artist = test_address('artist'); let beast_id = register_default(registry, artist, test_address('dungeon')); assert(registry.get_stats_source(beast_id) == 0.try_into().unwrap(), 'Stats default off'); + let stats_class = declare("mock_stats_source").unwrap().contract_class(); + let (stats_addr, _) = stats_class.deploy(@array![1]).unwrap(); // compliant = true + start_cheat_caller_address(registry.contract_address, artist); - registry.set_stats_source(beast_id, test_address('stats')); + registry.set_stats_source(beast_id, stats_addr); + assert(registry.get_stats_source(beast_id) == stats_addr, 'Stats source set'); + + // Zero clears without any external call. + registry.set_stats_source(beast_id, 0.try_into().unwrap()); stop_cheat_caller_address(registry.contract_address); + assert(registry.get_stats_source(beast_id) == 0.try_into().unwrap(), 'Stats cleared'); + } + + #[test] + #[should_panic(expected: 'Registry: bad stats source')] + fn test_set_stats_source_non_compliant_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + let stats_class = declare("mock_stats_source").unwrap().contract_class(); + let (stats_addr, _) = stats_class.deploy(@array![0]).unwrap(); // compliant = false + + start_cheat_caller_address(registry.contract_address, artist); + registry.set_stats_source(beast_id, stats_addr); + } - assert(registry.get_stats_source(beast_id) == test_address('stats'), 'Stats source set'); + // Note: setting an undeployed address as stats source reverts on-network + // (the SRC5 probe is a call to a non-existent contract). snforge surfaces + // that as a runner-level error rather than a catchable panic, so it is + // not expressible as a #[should_panic] test. + + // ---------------- art content validation ---------------- + + #[test] + #[should_panic(expected: 'Provider: bad art payload')] + fn test_update_art_quote_payload_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // A single quote would escape the SVG src attribute the renderer + // embeds this URI into. + registry + .update_art( + beast_id, + "data:image/png;base64,AA'onload='evil", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art prefix')] + fn test_update_art_wrong_prefix_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // A GIF data URI in a PNG slot fails the exact-prefix check. + registry + .update_art( + beast_id, + "data:image/gif;base64,AA==", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art prefix')] + fn test_update_art_empty_payload_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + registry + .update_art( + beast_id, + "data:image/png;base64,", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic] + fn test_register_with_markup_art_rejected() { + let (registry, _, _) = setup(); + // Validation also runs in the factory provider's constructor, so a + // registration carrying active content fails outright. + start_cheat_caller_address(registry.contract_address, test_address('artist')); + registry + .register_beast_with_art( + 'Gloomfang', + BeastType::Hunter, + 3, + test_address('dungeon'), + "data:image/svg+xml,", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); } // ---------------- stored art provider gating ---------------- diff --git a/src/interfaces.cairo b/src/interfaces.cairo index a244079..89f29a6 100644 --- a/src/interfaces.cairo +++ b/src/interfaces.cairo @@ -197,6 +197,27 @@ pub trait IBeastRegistry { fn set_stored_art_class_hash(ref self: TContractState, class_hash: starknet::ClassHash); } +/// Live combat stats served by an opt-in per-species stats source. +#[derive(Drop, Copy, Serde)] +pub struct BeastLiveStats { + pub adventurers_killed: u64, + pub last_killed_by: u64, + pub last_killed_timestamp: u64, +} + +/// SRC5 interface ID a compliant stats source must register. The registry +/// verifies it once, at `set_stats_source` time. Ecosystem-defined ID: +/// sn_keccak("beast_stats_v1"). +pub const IBEAST_STATS_ID: felt252 = selector!("beast_stats_v1"); + +/// Opt-in per-species kill-stats source (implemented by Death Mountain v2 or +/// any community dungeon). Stats are consumed by the NFT's cached +/// refresh-stats flow, never live from `token_uri`. +#[starknet::interface] +pub trait IBeastStats { + fn get_beast_stats(self: @TContractState, entity_hash: felt252) -> BeastLiveStats; +} + /// Registry-facing entrypoints implemented by the Beasts NFT contract. #[starknet::interface] pub trait IBeastsProvenance { diff --git a/src/stored_art_provider.cairo b/src/stored_art_provider.cairo index cfaa881..40844b2 100644 --- a/src/stored_art_provider.cairo +++ b/src/stored_art_provider.cairo @@ -30,6 +30,8 @@ pub mod stored_art_provider { gif_regular: ByteArray, gif_shiny: ByteArray, ) { + InternalTrait::assert_valid_art_set(@png_regular, @png_shiny, @gif_regular, @gif_shiny); + self.registry.write(registry); self.beast_id.write(beast_id); self.png_regular.write(png_regular); @@ -75,6 +77,7 @@ pub mod stored_art_provider { let caller = starknet::get_caller_address(); assert(caller == self.registry.read(), 'Provider: not registry'); assert(beast_id == self.beast_id.read(), 'Provider: wrong species'); + InternalTrait::assert_valid_art_set(@png_regular, @png_shiny, @gif_regular, @gif_shiny); self.png_regular.write(png_regular); self.png_shiny.write(png_shiny); @@ -90,4 +93,57 @@ pub mod stored_art_provider { self.beast_id.read() } } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn assert_valid_art_set( + png_regular: @ByteArray, + png_shiny: @ByteArray, + gif_regular: @ByteArray, + gif_shiny: @ByteArray, + ) { + Self::assert_valid_data_uri(png_regular, false); + Self::assert_valid_data_uri(png_shiny, false); + Self::assert_valid_data_uri(gif_regular, true); + Self::assert_valid_data_uri(gif_shiny, true); + } + + /// The renderer embeds these URIs verbatim inside a single-quoted SVG + /// attribute, and the factory provider carries the trusted + /// "verified art" designation — so the content must be provably + /// inert: an exact image data-URI prefix followed by a non-empty + /// standard-base64 payload. Quotes, markup, and control bytes cannot + /// pass the base64 charset. + fn assert_valid_data_uri(uri: @ByteArray, is_gif: bool) { + let prefix: ByteArray = if is_gif { + "data:image/gif;base64," + } else { + "data:image/png;base64," + }; + let prefix_len = prefix.len(); + assert(uri.len() > prefix_len, 'Provider: bad art prefix'); + + let mut i: u32 = 0; + while i < prefix_len { + assert(uri.at(i).unwrap() == prefix.at(i).unwrap(), 'Provider: bad art prefix'); + i += 1; + } + + let total_len = uri.len(); + let mut j = prefix_len; + while j < total_len { + assert(Self::is_base64_char(uri.at(j).unwrap()), 'Provider: bad art payload'); + j += 1; + } + } + + fn is_base64_char(byte: u8) -> bool { + (byte >= 'A' && byte <= 'Z') + || (byte >= 'a' && byte <= 'z') + || (byte >= '0' && byte <= '9') + || byte == '+' + || byte == '/' + || byte == '=' + } + } } From c592f3b4c3e2cc605b9c24c2d412849747d7560c Mon Sep 17 00:00:00 2001 From: loothero Date: Fri, 24 Jul 2026 21:18:32 -0700 Subject: [PATCH 3/6] Enforce base64 structure and image magic bytes in factory art Follow-up to the Codex re-review: the charset check alone admitted structurally malformed base64 (odd length, mid-string padding), which could be locked permanently as broken factory art. The validator now requires payload length % 4 == 0, '=' only in the final two positions (never '=X'), and the encoded image signature - PNG's magic bytes always base64-encode to the "iVBORw0KGgo" prefix and GIF87a/89a to "R0lGOD", so magic verification is a prefix comparison with no on-chain decoding. Co-Authored-By: Claude Fable 5 --- src/beast_registry_tests.cairo | 99 ++++++++++++++++++++++++++-------- src/stored_art_provider.cairo | 46 +++++++++++++--- 2 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/beast_registry_tests.cairo b/src/beast_registry_tests.cairo index 999777e..d81a073 100644 --- a/src/beast_registry_tests.cairo +++ b/src/beast_registry_tests.cairo @@ -109,12 +109,14 @@ mod tests { address.try_into().unwrap() } + // Payloads carry the encoded PNG ("iVBORw0KGgo") / GIF ("R0lGOD") magic + // bytes and valid base64 structure, as the provider now enforces. fn sample_art() -> (ByteArray, ByteArray, ByteArray, ByteArray) { ( - "data:image/png;base64,PNGREG", - "data:image/png;base64,PNGSHINY", - "data:image/gif;base64,GIFREG", - "data:image/gif;base64,GIFSHINY", + "data:image/png;base64,iVBORw0KGgoAAAA1", + "data:image/png;base64,iVBORw0KGgoAAAA2", + "data:image/gif;base64,R0lGODdhAAA1", + "data:image/gif;base64,R0lGODdhAAA2", ) } @@ -218,22 +220,15 @@ mod tests { assert(stored.get_species_id() == beast_id, 'Provider species'); let art = IBeastArtProviderDispatcher { contract_address: provider_addr }; + let (png_regular, png_shiny, gif_regular, gif_shiny) = sample_art(); assert( - art.get_data_uri(community_beast(beast_id, 0, 0)) == "data:image/png;base64,PNGREG", - 'regular png variant', - ); - assert( - art.get_data_uri(community_beast(beast_id, 1, 0)) == "data:image/png;base64,PNGSHINY", - 'shiny png variant', - ); - assert( - art.get_data_uri(community_beast(beast_id, 0, 1)) == "data:image/gif;base64,GIFREG", - 'regular gif variant', + art.get_data_uri(community_beast(beast_id, 0, 0)) == png_regular, 'regular png variant', ); + assert(art.get_data_uri(community_beast(beast_id, 1, 0)) == png_shiny, 'shiny png variant'); assert( - art.get_data_uri(community_beast(beast_id, 1, 1)) == "data:image/gif;base64,GIFSHINY", - 'shiny gif variant', + art.get_data_uri(community_beast(beast_id, 0, 1)) == gif_regular, 'regular gif variant', ); + assert(art.get_data_uri(community_beast(beast_id, 1, 1)) == gif_shiny, 'shiny gif variant'); } #[test] @@ -414,16 +409,19 @@ mod tests { registry .update_art( beast_id, - "data:image/png;base64,NEW", - "data:image/png;base64,NEWSHINY", - "data:image/gif;base64,NEWGIF", - "data:image/gif;base64,NEWGIFSHINY", + "data:image/png;base64,iVBORw0KGgoBBBB1", + "data:image/png;base64,iVBORw0KGgoBBBB2", + "data:image/gif;base64,R0lGODdhBBB1", + "data:image/gif;base64,R0lGODdhBBB2", ); stop_cheat_caller_address(registry.contract_address); let art = IBeastArtProviderDispatcher { contract_address: provider_addr }; assert( - art.get_data_uri(community_beast(beast_id, 0, 0)) == "data:image/png;base64,NEW", + art + .get_data_uri( + community_beast(beast_id, 0, 0), + ) == "data:image/png;base64,iVBORw0KGgoBBBB1", 'Art updated', ); assert(nft.fan_out_count() == 1, 'Fan-out triggered'); @@ -641,7 +639,64 @@ mod tests { registry .update_art( beast_id, - "data:image/png;base64,AA'onload='evil", + "data:image/png;base64,iVBORw0KGgo'AAAA", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art length')] + fn test_update_art_bad_base64_length_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // 13-character payload: not a multiple of 4. + registry + .update_art( + beast_id, + "data:image/png;base64,iVBORw0KGgoAA", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art payload')] + fn test_update_art_mid_padding_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // '=' before the final two positions is structurally invalid base64. + registry + .update_art( + beast_id, + "data:image/png;base64,iVBORw0KGgo=AAAA", + "data:image/png;base64,AA==", + "data:image/gif;base64,AA==", + "data:image/gif;base64,AA==", + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art magic')] + fn test_update_art_wrong_magic_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // Valid base64, but not a PNG: missing the encoded PNG signature. + registry + .update_art( + beast_id, + "data:image/png;base64,QUJDREVGR0hJSks=", "data:image/png;base64,AA==", "data:image/gif;base64,AA==", "data:image/gif;base64,AA==", diff --git a/src/stored_art_provider.cairo b/src/stored_art_provider.cairo index 40844b2..098ade5 100644 --- a/src/stored_art_provider.cairo +++ b/src/stored_art_provider.cairo @@ -110,10 +110,12 @@ pub mod stored_art_provider { /// The renderer embeds these URIs verbatim inside a single-quoted SVG /// attribute, and the factory provider carries the trusted - /// "verified art" designation — so the content must be provably - /// inert: an exact image data-URI prefix followed by a non-empty - /// standard-base64 payload. Quotes, markup, and control bytes cannot - /// pass the base64 charset. + /// "verified art" designation — so the content must be provably an + /// inert image: an exact data-URI prefix, a structurally valid + /// standard-base64 payload (length multiple of 4, padding only at + /// the end), and the encoded PNG/GIF magic bytes. The magic bytes + /// need no decoding: PNG's 8-byte signature always base64-encodes to + /// the prefix "iVBORw0KGgo", and GIF87a/89a headers to "R0lGOD". fn assert_valid_data_uri(uri: @ByteArray, is_gif: bool) { let prefix: ByteArray = if is_gif { "data:image/gif;base64," @@ -130,20 +132,52 @@ pub mod stored_art_provider { } let total_len = uri.len(); + let payload_len = total_len - prefix_len; + assert(payload_len % 4 == 0, 'Provider: bad art length'); + + // Encoded image magic bytes. + let magic: ByteArray = if is_gif { + "R0lGOD" + } else { + "iVBORw0KGgo" + }; + let magic_len = magic.len(); + assert(payload_len >= magic_len, 'Provider: bad art magic'); + let mut m: u32 = 0; + while m < magic_len { + assert( + uri.at(prefix_len + m).unwrap() == magic.at(m).unwrap(), + 'Provider: bad art magic', + ); + m += 1; + } + + // Base64 body: '=' padding may only appear in the final two + // positions, and "=X" is never valid. let mut j = prefix_len; - while j < total_len { + while j < total_len - 2 { assert(Self::is_base64_char(uri.at(j).unwrap()), 'Provider: bad art payload'); j += 1; } + let second_last = uri.at(total_len - 2).unwrap(); + let last = uri.at(total_len - 1).unwrap(); + assert( + Self::is_base64_char(second_last) || second_last == '=', + 'Provider: bad art payload', + ); + assert(Self::is_base64_char(last) || last == '=', 'Provider: bad art payload'); + if second_last == '=' { + assert(last == '=', 'Provider: bad art payload'); + } } + /// Strict base64 alphabet, excluding padding. fn is_base64_char(byte: u8) -> bool { (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z') || (byte >= '0' && byte <= '9') || byte == '+' || byte == '/' - || byte == '=' } } } From 6c9527537da3d7150c8b621abe8532e4e7dcbf32 Mon Sep 17 00:00:00 2001 From: loothero Date: Fri, 24 Jul 2026 21:27:35 -0700 Subject: [PATCH 4/6] Document render-time validation of custom provider output for PR 4 Response to the final Codex review finding on custom art providers: the species-scoped brick risk is accepted by design (self-inflicted, contained, and the reason the factory path carries the verified badge), but the injection half gets a concrete mitigation - token_uri will validate custom provider output (base64 image data URI, wider mime allowlist) before embedding, landing with the render path in the NFT integration PR. Co-Authored-By: Claude Fable 5 --- docs/community-beasts-design.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/community-beasts-design.md b/docs/community-beasts-design.md index 5e6461c..9744faf 100644 --- a/docs/community-beasts-design.md +++ b/docs/community-beasts-design.md @@ -266,6 +266,16 @@ species only, and only the artist's own pointer choice causes it. Factory providers are canonical-class and cannot revert. The web app badges "verified art" only for canonical-class providers. +**Render-time output validation (PR 4)**: `token_uri` validates whatever a +custom provider returns before embedding it — an exact +`data:image/{png,gif,svg+xml,webp};base64,` prefix plus base64-charset body +(same validator family as the factory provider, with a wider mime allowlist +to preserve dynamic-art optionality). A misbehaving custom provider can +therefore revert its own species' rendering (accepted: self-inflicted and +contained) but can never inject markup into the SVG or JSON. Factory +providers guarantee inertness at write time; custom providers get it +enforced at read time. + ## StoredArtProvider (canonical class) - Storage: 4 `ByteArray` data URIs + `registry` + `beast_id`. From 9078668e9c6b517009ecb55ed553735d054e778c Mon Sep 17 00:00:00 2001 From: loothero Date: Sun, 26 Jul 2026 18:37:51 -0700 Subject: [PATCH 5/6] Keep refresh path open for locked custom providers; verify GIF version Two review findings: - notify_art_updated now gates on art_locked only for factory providers. lock_art freezes a factory provider's output completely (its sole mutator is registry-gated set_art), so a refresh there could never carry new art. For a custom provider the lock freezes only the pointer - the provider contract may still change what it returns - so blocking notification would strand ERC-4906 consumers on permanently stale metadata for that species. The shared cooldown still applies. - GIF magic validation now requires a complete encoded signature: R0lGODdh (GIF87a) or R0lGODlh (GIF89a). The previous 6-character "R0lGOD" prefix admitted payloads such as R0lGODAAAAAA, which decodes to the invalid header GIF80. PNG unchanged (iVBORw0KGgo is already a complete signature plus the implied IHDR length byte). Tests: locked custom provider can still notify, locked factory cannot; GIF87a/GIF89a both accepted, invalid version and truncated magic rejected. 116 passing. Co-Authored-By: Claude Opus 5 (1M context) --- docs/community-beasts-design.md | 6 ++ src/beast_registry.cairo | 9 ++- src/beast_registry_tests.cairo | 105 ++++++++++++++++++++++++++++++++ src/stored_art_provider.cairo | 56 ++++++++++++----- 4 files changed, 159 insertions(+), 17 deletions(-) diff --git a/docs/community-beasts-design.md b/docs/community-beasts-design.md index 9744faf..aab4e5a 100644 --- a/docs/community-beasts-design.md +++ b/docs/community-beasts-design.md @@ -306,6 +306,12 @@ enforced at read time. the provider's internal behavior (it may be upgradable). Only factory-provider + locked = provably frozen art; the web app badges accordingly. +- Because of that asymmetry, `lock_art` gates `notify_art_updated` **only for + factory providers**, where a locked provider genuinely cannot produce new + art. A locked *custom* species keeps its refresh path: its provider can + still change what it returns, so blocking notification would strand + ERC-4906 consumers on permanently stale metadata. The cooldown still + applies. ## Kill stats — opt-in, cached, never live in token_uri diff --git a/src/beast_registry.cairo b/src/beast_registry.cairo index fcd793b..19ed9b5 100644 --- a/src/beast_registry.cairo +++ b/src/beast_registry.cairo @@ -265,7 +265,14 @@ pub mod beast_registry { fn notify_art_updated(ref self: ContractState, beast_id: u64) { InternalTrait::assert_only_artist(@self, beast_id); let meta = self.metas.entry(beast_id).read(); - assert(!meta.art_locked, 'Registry: art locked'); + // `lock_art` means different things per provider kind. A locked + // factory provider is genuinely frozen (its only mutator is + // registry-gated `set_art`), so a refresh could never carry new + // art and is rejected. A locked CUSTOM provider only has its + // pointer frozen — the provider contract may still change what it + // returns — so the refresh path must stay open, or ERC-4906 + // consumers would be permanently stale for that species. + assert(!(meta.art_locked && meta.factory_provider), 'Registry: art locked'); InternalTrait::assert_refresh_cooldown(ref self, beast_id); self.emit(ArtUpdated { beast_id }); diff --git a/src/beast_registry_tests.cairo b/src/beast_registry_tests.cairo index d81a073..3ea4c33 100644 --- a/src/beast_registry_tests.cairo +++ b/src/beast_registry_tests.cairo @@ -530,6 +530,41 @@ mod tests { registry.update_art(beast_id, "a", "b", "c", "d"); } + #[test] + fn test_notify_allowed_after_lock_for_custom_provider() { + let (registry, nft, _) = setup(); + let artist = test_address('artist'); + + start_cheat_caller_address(registry.contract_address, artist); + let beast_id = registry + .register_beast( + 'Voidling', BeastType::Magic, 1, test_address('dungeon'), test_address('custom'), + ); + + // Locking a custom provider freezes the pointer, not the provider's + // output, so the refresh path must stay open. + registry.lock_art(beast_id); + registry.notify_art_updated(beast_id); + stop_cheat_caller_address(registry.contract_address); + + assert(registry.is_art_locked(beast_id), 'Art still locked'); + assert(nft.fan_out_count() == 1, 'Refresh still available'); + } + + #[test] + #[should_panic(expected: 'Registry: art locked')] + fn test_notify_blocked_after_lock_for_factory_provider() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + // A locked factory provider is genuinely frozen: a refresh could + // never carry new art. + start_cheat_caller_address(registry.contract_address, artist); + registry.lock_art(beast_id); + registry.notify_art_updated(beast_id); + } + #[test] #[should_panic(expected: 'Registry: art locked')] fn test_lock_art_blocks_provider_swap() { @@ -684,6 +719,76 @@ mod tests { ); } + #[test] + #[should_panic(expected: 'Provider: bad art magic')] + fn test_update_art_invalid_gif_version_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // "R0lGODAAAAAA" starts with the shared "R0lGOD" prefix but decodes + // to the invalid header "GIF80"; only GIF87a ("R0lGODdh") and GIF89a + // ("R0lGODlh") are legal. + registry + .update_art( + beast_id, + "data:image/png;base64,iVBORw0KGgoAAAA1", + "data:image/png;base64,iVBORw0KGgoAAAA2", + "data:image/gif;base64,R0lGODAAAAAA", + "data:image/gif;base64,R0lGODdhAAA2", + ); + } + + #[test] + fn test_update_art_accepts_both_gif_versions() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // GIF87a in one slot, GIF89a in the other. + registry + .update_art( + beast_id, + "data:image/png;base64,iVBORw0KGgoAAAA1", + "data:image/png;base64,iVBORw0KGgoAAAA2", + "data:image/gif;base64,R0lGODdhAAA1", + "data:image/gif;base64,R0lGODlhAAA2", + ); + stop_cheat_caller_address(registry.contract_address); + + let art = IBeastArtProviderDispatcher { + contract_address: registry.get_art_provider(beast_id), + }; + assert( + art + .get_data_uri( + community_beast(beast_id, 1, 1), + ) == "data:image/gif;base64,R0lGODlhAAA2", + 'GIF89a accepted', + ); + } + + #[test] + #[should_panic(expected: 'Provider: bad art magic')] + fn test_update_art_truncated_gif_magic_rejected() { + let (registry, _, _) = setup(); + let artist = test_address('artist'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + start_cheat_caller_address(registry.contract_address, artist); + // Payload shorter than the 8-character encoded GIF signature. + registry + .update_art( + beast_id, + "data:image/png;base64,iVBORw0KGgoAAAA1", + "data:image/png;base64,iVBORw0KGgoAAAA2", + "data:image/gif;base64,R0lG", + "data:image/gif;base64,R0lGODdhAAA2", + ); + } + #[test] #[should_panic(expected: 'Provider: bad art magic')] fn test_update_art_wrong_magic_rejected() { diff --git a/src/stored_art_provider.cairo b/src/stored_art_provider.cairo index 098ade5..bff0fa9 100644 --- a/src/stored_art_provider.cairo +++ b/src/stored_art_provider.cairo @@ -135,22 +135,7 @@ pub mod stored_art_provider { let payload_len = total_len - prefix_len; assert(payload_len % 4 == 0, 'Provider: bad art length'); - // Encoded image magic bytes. - let magic: ByteArray = if is_gif { - "R0lGOD" - } else { - "iVBORw0KGgo" - }; - let magic_len = magic.len(); - assert(payload_len >= magic_len, 'Provider: bad art magic'); - let mut m: u32 = 0; - while m < magic_len { - assert( - uri.at(prefix_len + m).unwrap() == magic.at(m).unwrap(), - 'Provider: bad art magic', - ); - m += 1; - } + Self::assert_valid_magic(uri, prefix_len, payload_len, is_gif); // Base64 body: '=' padding may only appear in the final two // positions, and "=X" is never valid. @@ -171,6 +156,45 @@ pub mod stored_art_provider { } } + /// A fixed leading image signature always base64-encodes to a fixed + /// character prefix, so magic-byte verification is a prefix + /// comparison with no on-chain decoding: + /// PNG 89 50 4E 47 0D 0A 1A 0A + IHDR length -> "iVBORw0KGgo" + /// GIF "GIF87a" -> "R0lGODdh", "GIF89a" -> "R0lGODlh" + /// The GIF version characters must be checked too: "R0lGOD" alone + /// admits payloads like "R0lGODAAAAAA", which decodes to the invalid + /// header "GIF80". + fn assert_valid_magic(uri: @ByteArray, prefix_len: u32, payload_len: u32, is_gif: bool) { + if is_gif { + assert(payload_len >= 8, 'Provider: bad art magic'); + assert(Self::matches_at(uri, prefix_len, @"R0lGOD"), 'Provider: bad art magic'); + let version = uri.at(prefix_len + 6).unwrap(); + assert(version == 'd' || version == 'l', 'Provider: bad art magic'); + assert(uri.at(prefix_len + 7).unwrap() == 'h', 'Provider: bad art magic'); + } else { + assert(payload_len >= 11, 'Provider: bad art magic'); + assert( + Self::matches_at(uri, prefix_len, @"iVBORw0KGgo"), 'Provider: bad art magic', + ); + } + } + + /// Caller must guarantee `uri` has at least `offset + needle.len()` + /// bytes. + fn matches_at(uri: @ByteArray, offset: u32, needle: @ByteArray) -> bool { + let needle_len = needle.len(); + let mut i: u32 = 0; + let mut matched = true; + while i < needle_len { + if uri.at(offset + i).unwrap() != needle.at(i).unwrap() { + matched = false; + break; + } + i += 1; + } + matched + } + /// Strict base64 alphabet, excluding padding. fn is_base64_char(byte: u8) -> bool { (byte >= 'A' && byte <= 'Z') From 7a613d0bbc8ff5208ef0aa518434a2e8f4854474 Mon Sep 17 00:00:00 2001 From: loothero Date: Fri, 31 Jul 2026 21:22:25 -0700 Subject: [PATCH 6/6] Make the Genesis Beast the artist role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry stored an artists map alongside a transferable Genesis Beast, which is two sources of truth for one thing. Sell the creator token and an invisible role stayed behind; every UI then has to explain which one actually governs. Now there is one. Permissioned entrypoints resolve the artist as owner_of(genesis_token_id(beast_id)), so control moves with the token on any marketplace and the two can never diverge. transfer_artist_role is deleted — the ERC721 transfer is the transfer. genesis_token_id is derived rather than stored: encode_token_id over the canonical (id, 0, 0) shape with the species' registered traits. That shape now has a single definition in beast_manager::genesis_beast, because the registry and the NFT must compute the same token or the role would point at nothing. Costs taken knowingly: each permissioned write makes one owner_of call to the NFT (owner-set and write-once, so not an untrusted call), and mis-sending a Genesis Beast freezes that species' admin. Burning cannot cause that — the enumeration component rejects burns — but a bad transfer can, same as any NFT. Saves a storage slot per species and removes an entrypoint. Also unlocks client-side lookup: token_of_owner_by_index plus a local decode_token_id tells a wallet which species it controls, with no registry reads and no event scanning. Co-Authored-By: Claude Opus 5 (1M context) --- docs/community-beasts-design.md | 45 +++++++++++++-- src/beast_manager.cairo | 16 ++++-- src/beast_registry.cairo | 57 ++++++++++--------- src/beast_registry_tests.cairo | 98 ++++++++++++++++++++++++++------- src/interfaces.cairo | 9 ++- 5 files changed, 169 insertions(+), 56 deletions(-) diff --git a/docs/community-beasts-design.md b/docs/community-beasts-design.md index aab4e5a..a4b7d12 100644 --- a/docs/community-beasts-design.md +++ b/docs/community-beasts-design.md @@ -18,6 +18,7 @@ fully on-chain and are mintable through Loot Survivor dungeons. | 2 | Token ID encoding | all static data in a deterministic **116-bit** token ID; zero per-beast storage | | 3 | Art model | per-species `IBeastArtProvider` address; factory auto-deploys canonical `StoredArtProvider`; custom providers allowed | | 4 | Provenance | artist receives the species' Genesis Beast at registration | +| 20 | Artist role | **the Genesis Beast IS the artist role.** No stored `artists` map: every permissioned entrypoint resolves the artist as `owner_of(genesis_token_id(beast_id))`, so control transfers with the token on any marketplace and the two can never drift apart | | 5 | Art locking | one-way `lock_art` | | 6 | Minter locking | one-way `lock_minter` | | 7 | Registration fee | **none** — no fee mechanism at all (no shared resource to protect once name uniqueness was dropped) | @@ -123,14 +124,14 @@ pub struct BeastDefinition { pub beast_type: BeastType, pub tier: u8, // 1..=5 pub minter: ContractAddress, // dungeon allowed to mint this species - pub artist: ContractAddress, // registrant; per-species admin + pub artist: ContractAddress, // derived: holder of the Genesis Beast pub art_provider: ContractAddress, pub stats_source: ContractAddress, // 0 = no kill stats (default) pub factory_provider: bool, pub art_locked: bool, pub minter_locked: bool, } -// Stored manually packed: name | artist | minter | art_provider = 4 slots, +// Stored manually packed: name | minter | art_provider = 3 slots, // meta (tier 3b | type 3b | factory 1b | art_locked 1b | minter_locked 1b) = 1 slot. // stats_source gets a slot only when set (zero-default maps cost nothing unwritten). @@ -172,7 +173,6 @@ pub trait IBeastRegistry { fn notify_art_updated(ref self: T, beast_id: u64); // custom providers: trigger refresh fn lock_art(ref self: T, beast_id: u64); // one-way fn set_stats_source(ref self: T, beast_id: u64, source: ContractAddress); - fn transfer_artist_role(ref self: T, beast_id: u64, new_artist: ContractAddress); // non-zero // -------- reads -------- fn get_definition(self: @T, beast_id: u64) -> BeastDefinition; @@ -347,6 +347,44 @@ the canonical entity hash everywhere is `poseidon(id: u64, prefix, suffix)` — matching the widened `pack::get_hash`. No silent `u64 → u8` truncation anywhere; legacy conversions are checked. +## The artist role is the Genesis Beast + +There is no `artists` map. Every permissioned entrypoint resolves the artist +by asking the NFT who holds the species' Genesis Beast: + +```cairo +fn artist_of(self: @ContractState, beast_id: u64) -> ContractAddress { + IERC721Dispatcher { contract_address: self.nft.read() } + .owner_of(Self::genesis_token_id(self, beast_id)) +} +``` + +`genesis_token_id` is derived, not stored: it is `encode_token_id` over the +canonical `(id, 0, 0)` shape with the species' registered tier and type, so +the registry and any client compute the same value offline. + +Why this over a stored role: + +- **The creator token means what it says.** Selling it on any marketplace + hands over the species; there is no second, invisible role that stays + behind. +- **They cannot drift.** A stored role plus a transferable token is two + sources of truth, and every UI then has to explain which one governs. +- **Enumeration answers everything.** `token_of_owner_by_index` plus a local + `decode_token_id` tells a client which species a wallet controls, with no + registry reads and no event scanning. +- **Less state and less surface**: one storage slot per species saved, and + `transfer_artist_role` deleted entirely. + +Costs, accepted deliberately: + +- Each permissioned write now makes one `owner_of` call to the NFT. The NFT + is owner-set and write-once, so this is not an untrusted call. +- Sending a Genesis Beast to an address nobody controls would freeze that + species' admin permanently. Burning cannot cause this — the enumeration + component rejects burns outright — but a mis-sent transfer can. This is the + same risk profile as any NFT and the UI warns before transfer. + ## Provenance mint (community Genesis Beasts) At registration, `beasts_nft.mint_provenance(artist, beast_id)` mints the @@ -435,7 +473,6 @@ art set. | Where | What | Slots | |---|---|---| | Registry | `name` | 1 | -| Registry | `artist` | 1 | | Registry | `minter` | 1 | | Registry | `art_provider` | 1 | | Registry | packed meta (tier, type, factory, art_locked, minter_locked) | 1 | diff --git a/src/beast_manager.cairo b/src/beast_manager.cairo index 90d35d7..b329374 100644 --- a/src/beast_manager.cairo +++ b/src/beast_manager.cairo @@ -90,9 +90,18 @@ pub impl BeastManagerImpl of BeastManagerTrait { } let (tier, beast_type) = Self::resolve_species_traits(beast_id); + BeastResult::Ok(Self::genesis_beast(beast_id, tier, beast_type)) + } - // Create genesis beast with default attributes - let beast = PackableBeast { + /// The canonical Genesis Beast of a species: the reserved `(id, 0, 0)` + /// affix slot, always level 1, health 100, shiny and animated. + /// + /// Pure and shared on purpose. Its token ID identifies the species' + /// creator token, and the registry derives that ID to answer "who is the + /// artist" — so the shape must have exactly one definition or the two + /// sides would compute different tokens. + fn genesis_beast(beast_id: u64, tier: u8, beast_type: u8) -> PackableBeast { + PackableBeast { id: beast_id, prefix: 0, suffix: 0, @@ -102,8 +111,7 @@ pub impl BeastManagerImpl of BeastManagerTrait { animated: 1, tier, beast_type, - }; - BeastResult::Ok(beast) + } } /// Resolves the static tier/type for a species. diff --git a/src/beast_registry.cairo b/src/beast_registry.cairo index 19ed9b5..6fa5530 100644 --- a/src/beast_registry.cairo +++ b/src/beast_registry.cairo @@ -13,16 +13,19 @@ pub mod beast_registry { use core::num::traits::Zero; use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ClassHash, ContractAddress}; + use super::super::beast_manager::BeastManagerTrait; use super::super::interfaces::{ BeastDefinition, BeastType, IBEAST_STATS_ID, IBeastRegistry, IBeastsProvenanceDispatcher, IBeastsProvenanceDispatcherTrait, IStoredArtProviderDispatcher, IStoredArtProviderDispatcherTrait, }; + use super::super::pack::encode_token_id; use super::{SpeciesMeta, assert_valid_name}; /// The first community species ID; 1-75 are genesis species. @@ -45,7 +48,6 @@ pub mod beast_registry { ownable: OwnableComponent::Storage, // Per-species definition. `meta` packs tier/type/flags into one slot. names: Map, - artists: Map, minters: Map, art_providers: Map, factory_providers: Map, // canonical factory deploy, 0 if none @@ -109,14 +111,6 @@ pub mod beast_registry { pub stats_source: ContractAddress, } - #[derive(Drop, starknet::Event)] - pub struct ArtistTransferred { - #[key] - pub beast_id: u64, - pub previous_artist: ContractAddress, - pub new_artist: ContractAddress, - } - #[event] #[derive(Drop, starknet::Event)] enum Event { @@ -129,7 +123,6 @@ pub mod beast_registry { ArtProviderUpdated: ArtProviderUpdated, ArtLocked: ArtLocked, StatsSourceUpdated: StatsSourceUpdated, - ArtistTransferred: ArtistTransferred, } #[constructor] @@ -304,17 +297,6 @@ pub mod beast_registry { self.emit(StatsSourceUpdated { beast_id, stats_source: source }); } - fn transfer_artist_role( - ref self: ContractState, beast_id: u64, new_artist: ContractAddress, - ) { - InternalTrait::assert_only_artist(@self, beast_id); - assert(new_artist.is_non_zero(), 'Registry: zero artist'); - - let previous_artist = self.artists.entry(beast_id).read(); - self.artists.entry(beast_id).write(new_artist); - self.emit(ArtistTransferred { beast_id, previous_artist, new_artist }); - } - fn get_definition(self: @ContractState, beast_id: u64) -> BeastDefinition { InternalTrait::assert_registered(self, beast_id); let meta = self.metas.entry(beast_id).read(); @@ -324,7 +306,7 @@ pub mod beast_registry { beast_type: meta.beast_type, tier: meta.tier, minter: self.minters.entry(beast_id).read(), - artist: self.artists.entry(beast_id).read(), + artist: InternalTrait::artist_of(self, beast_id), art_provider: self.art_providers.entry(beast_id).read(), stats_source: self.stats_sources.entry(beast_id).read(), factory_provider: meta.factory_provider, @@ -338,7 +320,12 @@ pub mod beast_registry { } fn get_artist(self: @ContractState, beast_id: u64) -> ContractAddress { - self.artists.entry(beast_id).read() + InternalTrait::artist_of(self, beast_id) + } + + fn get_genesis_token_id(self: @ContractState, beast_id: u64) -> u256 { + InternalTrait::assert_registered(self, beast_id); + InternalTrait::genesis_token_id(self, beast_id) } fn get_art_provider(self: @ContractState, beast_id: u64) -> ContractAddress { @@ -428,7 +415,6 @@ pub mod beast_registry { let type_code: u8 = beast_type.into(); self.names.entry(beast_id).write(name); - self.artists.entry(beast_id).write(artist); self.minters.entry(beast_id).write(minter); self.art_providers.entry(beast_id).write(art_provider); self @@ -465,10 +451,31 @@ pub mod beast_registry { ); } + /// Token ID of the species' Genesis Beast — the reserved `(id, 0, 0)` + /// affix slot minted to the registrant. Derived, never stored: the ID + /// is a pure function of the species and its traits, so storing it + /// would only create a second source of truth. + fn genesis_token_id(self: @ContractState, beast_id: u64) -> u256 { + let meta = self.metas.entry(beast_id).read(); + encode_token_id(BeastManagerTrait::genesis_beast(beast_id, meta.tier, meta.beast_type)) + } + + /// The artist IS whoever holds the Genesis Beast. + /// + /// The role is not stored separately, so it cannot drift from the + /// token: selling the creator token hands over the species, and there + /// is no way to end up holding one without the other. The token is + /// minted during registration and Beasts cannot be burned, so this + /// never reverts for a registered species. + fn artist_of(self: @ContractState, beast_id: u64) -> ContractAddress { + IERC721Dispatcher { contract_address: self.nft.read() } + .owner_of(Self::genesis_token_id(self, beast_id)) + } + fn assert_only_artist(self: @ContractState, beast_id: u64) { Self::assert_registered(self, beast_id); let caller = starknet::get_caller_address(); - assert(caller == self.artists.entry(beast_id).read(), 'Registry: not artist'); + assert(caller == Self::artist_of(self, beast_id), 'Registry: not artist'); } fn assert_refresh_cooldown(ref self: ContractState, beast_id: u64) { diff --git a/src/beast_registry_tests.cairo b/src/beast_registry_tests.cairo index 3ea4c33..3b362a8 100644 --- a/src/beast_registry_tests.cairo +++ b/src/beast_registry_tests.cairo @@ -2,11 +2,21 @@ /// provenance mint and fan-out call so tests can verify the registry drives /// the NFT correctly (the real implementation lands with the NFT /// integration). +/// +/// It also tracks token ownership, because the registry now resolves the +/// artist by asking the NFT who holds the species' Genesis Beast — so a mock +/// that cannot answer `owner_of` cannot exercise a single permissioned path. #[starknet::contract] pub mod mock_beasts_nft { use starknet::ContractAddress; - use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; - use super::super::interfaces::IBeastsProvenance; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use super::super::beast_manager::BeastManagerTrait; + use super::super::interfaces::{ + IBeastRegistryDispatcher, IBeastRegistryDispatcherTrait, IBeastsProvenance, + }; + use super::super::pack::encode_token_id; #[starknet::interface] pub trait IMockCounters { @@ -14,6 +24,10 @@ pub mod mock_beasts_nft { fn provenance_count(self: @TContractState) -> u64; fn last_fan_out(self: @TContractState) -> u64; fn fan_out_count(self: @TContractState) -> u64; + fn set_registry(ref self: TContractState, registry: ContractAddress); + /// Stand-in for a marketplace sale of the creator token. + fn transfer(ref self: TContractState, token_id: u256, to: ContractAddress); + fn owner_of(self: @TContractState, token_id: u256) -> ContractAddress; } #[storage] @@ -23,6 +37,8 @@ pub mod mock_beasts_nft { provenance_count: u64, last_fan_out_id: u64, fan_out_count: u64, + registry: ContractAddress, + owners: Map, } #[abi(embed_v0)] @@ -31,6 +47,17 @@ pub mod mock_beasts_nft { self.last_provenance_artist.write(artist); self.last_provenance_id.write(beast_id); self.provenance_count.write(self.provenance_count.read() + 1); + + // Mirror the real contract: the Genesis Beast lands with the + // registrant, which is what makes them the artist. + let (tier, beast_type) = IBeastRegistryDispatcher { + contract_address: self.registry.read(), + } + .get_species_traits(beast_id); + let token_id = encode_token_id( + BeastManagerTrait::genesis_beast(beast_id, tier, beast_type), + ); + self.owners.entry(token_id).write(artist); } fn emit_species_metadata_update(ref self: ContractState, beast_id: u64) { @@ -56,6 +83,18 @@ pub mod mock_beasts_nft { fn fan_out_count(self: @ContractState) -> u64 { self.fan_out_count.read() } + + fn set_registry(ref self: ContractState, registry: ContractAddress) { + self.registry.write(registry); + } + + fn transfer(ref self: ContractState, token_id: u256, to: ContractAddress) { + self.owners.entry(token_id).write(to); + } + + fn owner_of(self: @ContractState, token_id: u256) -> ContractAddress { + self.owners.entry(token_id).read() + } } } @@ -91,13 +130,14 @@ pub mod mock_stats_source { #[cfg(test)] mod tests { + use beasts_nft::beast_manager::BeastManagerTrait; use beasts_nft::beast_registry::beast_registry::ART_REFRESH_COOLDOWN_SECONDS; use beasts_nft::interfaces::{ BeastType, IBeastArtProviderDispatcher, IBeastArtProviderDispatcherTrait, IBeastRegistryDispatcher, IBeastRegistryDispatcherTrait, IStoredArtProviderDispatcher, IStoredArtProviderDispatcherTrait, }; - use beasts_nft::pack::PackableBeast; + use beasts_nft::pack::{PackableBeast, encode_token_id}; use snforge_std::{ ContractClassTrait, DeclareResultTrait, declare, start_cheat_block_timestamp, start_cheat_caller_address, stop_cheat_caller_address, @@ -155,7 +195,12 @@ mod tests { registry.set_nft_address(nft_address); stop_cheat_caller_address(registry_address); - (registry, IMockCountersDispatcher { contract_address: nft_address }, owner) + // The mock reads species traits back from the registry to derive the + // Genesis Beast it mints, exactly as the real NFT does. + let nft = IMockCountersDispatcher { contract_address: nft_address }; + nft.set_registry(registry_address); + + (registry, nft, owner) } fn register_default( @@ -578,45 +623,56 @@ mod tests { } #[test] - fn test_transfer_artist_role() { - let (registry, _, _) = setup(); + fn test_selling_the_genesis_beast_transfers_control() { + // The creator token IS the artist role. Moving it on any marketplace + // hands over the species — there is no separate role to transfer, and + // no way to hold one without the other. + let (registry, nft, _) = setup(); let artist = test_address('artist'); - let new_artist = test_address('new_artist'); + let buyer = test_address('buyer'); let beast_id = register_default(registry, artist, test_address('dungeon')); - start_cheat_caller_address(registry.contract_address, artist); - registry.transfer_artist_role(beast_id, new_artist); - stop_cheat_caller_address(registry.contract_address); + assert(registry.get_artist(beast_id) == artist, 'Registrant is the artist'); + + nft.transfer(registry.get_genesis_token_id(beast_id), buyer); - assert(registry.get_artist(beast_id) == new_artist, 'Artist transferred'); + assert(registry.get_artist(beast_id) == buyer, 'Buyer is now the artist'); - // New artist has admin rights. - start_cheat_caller_address(registry.contract_address, new_artist); + start_cheat_caller_address(registry.contract_address, buyer); registry.set_minter(beast_id, test_address('their_dungeon')); stop_cheat_caller_address(registry.contract_address); + assert(registry.get_minter(beast_id) == test_address('their_dungeon'), 'Buyer can admin'); } #[test] #[should_panic(expected: 'Registry: not artist')] - fn test_old_artist_loses_rights_after_transfer() { - let (registry, _, _) = setup(); + fn test_seller_loses_control_with_the_token() { + let (registry, nft, _) = setup(); let artist = test_address('artist'); let beast_id = register_default(registry, artist, test_address('dungeon')); + nft.transfer(registry.get_genesis_token_id(beast_id), test_address('buyer')); + start_cheat_caller_address(registry.contract_address, artist); - registry.transfer_artist_role(beast_id, test_address('new_artist')); registry.set_minter(beast_id, test_address('their_dungeon')); } #[test] - #[should_panic(expected: 'Registry: zero artist')] - fn test_transfer_artist_to_zero_rejected() { - let (registry, _, _) = setup(); + fn test_genesis_token_id_is_derivable_and_matches_the_mint() { + // The registry derives this ID rather than storing it, so it has to + // agree with the token the NFT actually minted. + let (registry, nft, _) = setup(); let artist = test_address('artist'); let beast_id = register_default(registry, artist, test_address('dungeon')); - start_cheat_caller_address(registry.contract_address, artist); - registry.transfer_artist_role(beast_id, 0.try_into().unwrap()); + let token_id = registry.get_genesis_token_id(beast_id); + assert(nft.owner_of(token_id) == artist, 'Derived ID matches the mint'); + + let (tier, beast_type) = registry.get_species_traits(beast_id); + let expected = encode_token_id( + BeastManagerTrait::genesis_beast(beast_id, tier, beast_type), + ); + assert(token_id == expected, 'ID is a pure derivation'); } #[test] diff --git a/src/interfaces.cairo b/src/interfaces.cairo index 89f29a6..366e1cb 100644 --- a/src/interfaces.cairo +++ b/src/interfaces.cairo @@ -122,7 +122,7 @@ pub struct BeastDefinition { pub beast_type: u8, // type code: 0 = Magic, 1 = Hunter, 2 = Brute pub tier: u8, // 1..=5 pub minter: ContractAddress, // dungeon allowed to mint this species; 0 = paused - pub artist: ContractAddress, // registrant; per-species admin + pub artist: ContractAddress, // holder of the Genesis Beast; per-species admin pub art_provider: ContractAddress, pub stats_source: ContractAddress, // 0 = no kill stats pub factory_provider: bool, @@ -175,12 +175,17 @@ pub trait IBeastRegistry { fn notify_art_updated(ref self: TContractState, beast_id: u64); fn lock_art(ref self: TContractState, beast_id: u64); fn set_stats_source(ref self: TContractState, beast_id: u64, source: ContractAddress); - fn transfer_artist_role(ref self: TContractState, beast_id: u64, new_artist: ContractAddress); // -------- reads -------- fn get_definition(self: @TContractState, beast_id: u64) -> BeastDefinition; fn get_minter(self: @TContractState, beast_id: u64) -> ContractAddress; + /// Whoever currently holds the species' Genesis Beast. The artist role is + /// not stored: the creator token *is* the role, so transferring it on any + /// marketplace transfers control of the species. fn get_artist(self: @TContractState, beast_id: u64) -> ContractAddress; + /// Token ID of the species' Genesis Beast. Derived from the species and + /// its traits, so clients can compute it offline too. + fn get_genesis_token_id(self: @TContractState, beast_id: u64) -> u256; fn get_art_provider(self: @TContractState, beast_id: u64) -> ContractAddress; fn get_stats_source(self: @TContractState, beast_id: u64) -> ContractAddress; fn get_species_traits(self: @TContractState, beast_id: u64) -> (u8, u8); // (tier, type)