diff --git a/docs/community-beasts-design.md b/docs/community-beasts-design.md index 5e6461c..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; @@ -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`. @@ -296,6 +306,12 @@ providers are canonical-class and cannot revert. The web app badges 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 @@ -331,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 @@ -419,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 new file mode 100644 index 0000000..6fa5530 --- /dev/null +++ b/src/beast_registry.cairo @@ -0,0 +1,679 @@ +/// 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 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. + 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, + 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, + } + + #[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, + } + + #[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'); + // 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 + // 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, + }, + ); + InternalTrait::notify_nft_art_updated(ref self, beast_id); + } + + 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(); + // `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 }); + 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); + + // 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 }); + } + + 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: 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, + 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 { + 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 { + 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.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', + ); + } + + /// 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::artist_of(self, beast_id), '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..3b362a8 --- /dev/null +++ b/src/beast_registry_tests.cairo @@ -0,0 +1,951 @@ +/// 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). +/// +/// 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::{ + 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 { + 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; + 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] + struct Storage { + last_provenance_artist: ContractAddress, + last_provenance_id: u64, + provenance_count: u64, + last_fan_out_id: u64, + fan_out_count: u64, + registry: ContractAddress, + owners: Map, + } + + #[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); + + // 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) { + 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() + } + + 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() + } + } +} + +/// 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_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, encode_token_id}; + 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() + } + + // 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,iVBORw0KGgoAAAA1", + "data:image/png;base64,iVBORw0KGgoAAAA2", + "data:image/gif;base64,R0lGODdhAAA1", + "data:image/gif;base64,R0lGODdhAAA2", + ) + } + + 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); + + // 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( + 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 }; + let (png_regular, png_shiny, gif_regular, gif_shiny) = sample_art(); + assert( + 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, 0, 1)) == gif_regular, 'regular gif variant', + ); + assert(art.get_data_uri(community_beast(beast_id, 1, 1)) == gif_shiny, '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,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,iVBORw0KGgoBBBB1", + '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')); + + 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, png_regular, png_shiny, gif_regular, gif_shiny); + // 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, 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, 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. 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() { + 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] + 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() { + 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_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 buyer = test_address('buyer'); + let beast_id = register_default(registry, artist, test_address('dungeon')); + + 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) == buyer, 'Buyer is now the 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_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.set_minter(beast_id, test_address('their_dungeon')); + } + + #[test] + 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')); + + 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] + 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, 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); + } + + // 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,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_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() { + 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==", + ); + } + + #[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 ---------------- + + #[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..366e1cb 100644 --- a/src/interfaces.cairo +++ b/src/interfaces.cairo @@ -67,11 +67,171 @@ 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, // holder of the Genesis Beast; 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); + + // -------- 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) + 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); +} + +/// 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 { + /// 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..bff0fa9 --- /dev/null +++ b/src/stored_art_provider.cairo @@ -0,0 +1,207 @@ +/// 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, + ) { + 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); + 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'); + 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); + 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() + } + } + + #[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 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," + } 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 payload_len = total_len - prefix_len; + assert(payload_len % 4 == 0, 'Provider: bad art length'); + + 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. + let mut j = prefix_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'); + } + } + + /// 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') + || (byte >= 'a' && byte <= 'z') + || (byte >= '0' && byte <= '9') + || byte == '+' + || byte == '/' + } + } +}