diff --git a/docs/community-beasts-design.md b/docs/community-beasts-design.md index a4b7d12..56aadf5 100644 --- a/docs/community-beasts-design.md +++ b/docs/community-beasts-design.md @@ -32,7 +32,8 @@ fully on-chain and are mintable through Loot Survivor dungeons. | 15 | Art size caps | none on-chain — if the artist pays and the network accepts, it's valid | | 16 | Death Mountain | not backwards compatible; DM gets a new interface version with `u64` entity IDs | | 17 | Downstream consumers | games (Summit, tournaments, …) MUST allowlist species; self-declared tier makes unfiltered acceptance exploitable | -| 18 | Migration | current collection airdropped **1:1** into the new collection (holders keep their Beasts, re-encoded under the new token IDs); species 1–75 backfilled into the registry as read-only entries | +| 18 | Migration | holders move 1:1 into the new collection. Mainnet target is a player-initiated **`burn_and_mint`**: burn the V2 Beast, mint the V3 equivalent. Deferred — not built yet, and nothing in the design may foreclose it (see Migration) | +| 19 | Registry backfill of 1–75 | **deferred**, not required. Species 1–75 resolve from the baked-in `beast_definitions` tables; the registry is community-only (`FIRST_COMMUNITY_ID = 76`). Backfill stays possible later because no read path asserts a lower bound on registry keys | ## Architecture @@ -548,14 +549,46 @@ costs 0 slots** — everything static rides in the token ID. `refresh_stats` - Lost artist key = species config frozen as-is; minting continues through the current minter unaffected. -## Migration: 1:1 airdrop from the current collection +## Migration: holders move 1:1 into the new collection -Every holder of the live collection receives the same Beasts in the new +Every holder of the live collection ends up with the same Beasts in the new collection — same `(id, prefix, suffix, level, health, shiny, animated)`, -re-encoded under the 116-bit deterministic token IDs. Key property: **this -requires zero migration-specific code in `beasts_nft`.** - -### Mechanism +re-encoded under the 116-bit deterministic token IDs. + +### Mainnet target: player-initiated `burn_and_mint` (deferred) + +The intended mainnet path is **not** an owner-run airdrop but a `burn_and_mint` +entrypoint players call themselves: present a V2 Beast, burn it, receive the +V3 equivalent. That keeps the migration permissionless, makes the V2 supply +verifiably retired, and removes the owner from the critical path. + +**This is deferred and deliberately unbuilt.** The constraint carried through +every PR is that nothing may foreclose it. Concretely, what keeps the door +open: + +- Mint validation lives in reusable helpers (`assert_can_mint`, + `MintingCoordinatorTrait::prepare_mint_with_traits`) rather than inlined in + the `mint` entrypoint, so a second entrypoint can reuse the whole chain + without relaxing any invariant. +- The `minted` uniqueness map starts empty except for the 75 genesis affix + slots, so every `(id, prefix, suffix)` a V2 holder could present is still + free to claim. +- Species 1–75 resolve tier/type/name from the baked-in tables with no + registry involvement, so burn-and-mint of an original Beast needs no + registry entry to exist. +- Token IDs are a pure function of beast attributes, so the V3 ID for any V2 + Beast is computable off-chain before the contract exists. + +One live hazard to respect at cutover: because V2 Beasts and V3 dungeon mints +draw from the same `(id, prefix, suffix)` space, a dungeon could mint a slot a +V2 holder still needs. Mainnet deploy must therefore leave `dungeon_address` +at zero (and community species paused, or registration closed) until +migration completes. + +### Fallback mechanism: owner-run airdrop + +Retained as the fallback if `burn_and_mint` slips. Requires zero +migration-specific code in `beasts_nft`. - **Snapshot**: index the old collection (owners + `get_beast` per token) at a stable block. If the old collection's `terminal_timestamp` has passed it is @@ -595,14 +628,19 @@ writes for large species. Cost per beast ≈ the normal ~8 slot writes. - **Rarity/uniqueness**: `(id, prefix, suffix)` entries repopulate through the normal `minted` map. -### Registry backfill of species 1–75 +### Registry backfill of species 1–75 — deferred, not required + +Backfill was considered and **dropped from scope**. Species 1–75 resolve +name, tier, and type from `beast_definitions`, authorize against +`dungeon_address`, and render through the four legacy art contracts — all +without the registry. A backfill would only add a second, redundant lookup +surface for clients. -Species 1–75 get read-only registry entries at deploy (name/tier/type from -the genesis tables, artist = contract owner, minter mirroring -`dungeon_address`, art provider = a thin adapter over the legacy data -contracts or zero with the NFT's legacy branch as the renderer). Clients get -one lookup surface for all species; the NFT's auth and rendering branches for -`id <= 75` remain the on-chain source of truth. +It stays available later: `FIRST_COMMUNITY_ID = 76` gates *registration* +only, and no registry read path asserts a lower bound on a stored key, so a +future admin entrypoint could write entries for 1–75 without touching the +existing logic. `is_registered` and `assert_registered` would need their +range check widened at that point; nothing else would. ### Cutover sequence @@ -617,13 +655,22 @@ one lookup surface for all species; the NFT's auth and rendering branches for ## Deployment sequence (fresh deploy, per repo policy) -1. Declare `StoredArtProvider` class. -2. Deploy `BeastRegistry(owner, stored_art_class_hash)`. -3. Deploy `beasts_nft(..., registry_address)` — constructor mints the 75 - genesis beasts (entered into `minted`) and backfills species 1–75 into - the registry. No `terminal_timestamp` param. -4. `registry.set_nft_address(nft)` — one-time; registry reverts all +1. Declare and deploy the four genesis art data contracts. +2. Declare `StoredArtProvider` class (never deployed directly — the registry + deploys instances per species). +3. Deploy `BeastRegistry(owner, stored_art_class_hash)`. +4. Deploy `beasts_nft(name, symbol, owner, royalty_receiver, + royalty_fraction, 4 art providers, death_mountain)` — constructor mints + the 75 genesis beasts (entered into `minted`). No `terminal_timestamp` + param, and no registry param: the two contracts are wired after the fact + because each needs the other's address. +5. `registry.set_nft_address(nft)` — one-time; registry reverts all registration until this is called. +6. `nft.set_registry_address(registry)` — one-time; community-species mints + and renders revert until this is called. + +Steps 5 and 6 are both write-once and both required; a stack missing either +accepts no community species at all, which is the intended fail-closed state. ## Web app flow @@ -683,15 +730,18 @@ Curation is UI/indexer-level only; the contract layer stays permissionless. branch first, which includes it). 3. **PR 3 — registry + provider**: `BeastRegistry`, `StoredArtProvider`, factory, `IBeastArtProvider`, name charset guard, registry tests. -4. **PR 4 — NFT integration**: mint auth, provenance mint, `token_uri` - routing, fan-out fixes (genesis token + bookmark min), cached - `refresh_stats`, integration tests. +4. **PR 4 — NFT integration**: per-species mint auth, provenance mint, + `token_uri` art/name/stats routing, render-time output validation for + untrusted providers, fan-out fix (genesis token), cached `refresh_stats`, + end-to-end integration tests. Stacked on PR 3 rather than on `main` — + the registry is not merged to `main` until the whole stack has been + exercised on Sepolia. 5. **PR 5 — SDK + web app**: TS SDK encode/decode + renderer; self-service app (likely separate repo). Death Mountain `u64` interface update tracked in the DM repo. -6. **PR 6 — migration tooling**: snapshot script, per-species - descending-power sort, `Migrator` contract + batch runner, cutover - runbook. Needed before mainnet deploy, independent of PRs 3–5. +6. **PR 6 — migration**: `burn_and_mint` entrypoint (see Migration), plus + snapshot/verification tooling and the cutover runbook. Needed before + mainnet, independent of PRs 3–5. ## Remaining open items diff --git a/docs/sepolia-v3-deployment.md b/docs/sepolia-v3-deployment.md new file mode 100644 index 0000000..e26fc0e --- /dev/null +++ b/docs/sepolia-v3-deployment.md @@ -0,0 +1,94 @@ +# Beasts V3 — Sepolia deployment + +Deployed 2026-07-31 from branch `feat/nft-registry-integration` (PR #20, +stacked on #19). Tooling: **sncast 0.60.0** (`--network sepolia`). Not +starkli — it is unsupported and absent from this environment. + +Deployer / owner / royalty receiver: sncast account `commit-reveal-sepolia` +`0x736faa0dca6a4569bf22471b574ddf42107f5af81d67e2cb9e1aa9bba7de76b` + +Total cost: ~10 STRK. + +## Deployed contracts + +| Contract | Address | +|---|---| +| **beasts_nft** | `0x01dac77837c6751777d917051a6e405967c5c75f46df5ab7c635e52819634bfd` | +| **beast_registry** | `0x06d46c98087a1246182c6cd8ef144ee0a67da6e6cc9e44e39aef08cf92d30045` | +| beast_png_regular_data | `0x045f6cf8249ebee56f699a46cb66f02cbd23419f1c2cd3e62a3dfdedaf894279` | +| beast_png_shiny_data | `0x0291ad81a428262fd709f0075dedf69814173bf5b60f989cf7095f5efa72c670` | +| beast_gif_regular_data | `0x04a15db02fc7c991f2080e349cfbfb8f96f5fd61dd92c1cbe60ed7dbd4d49bfe` | +| beast_gif_shiny_data | `0x07bf05b8aa73d7fe6cc3bfb67efae46daa1260bd71634f08c43fd555cdf17631` | + +## Declared class hashes + +| Contract | Class hash | +|---|---| +| beasts_nft | `0x350e97a3244fecad9f850d84843a0effc26a364c392c2e8c4379cb5de0193ea` | +| beast_registry | `0x2afeefe9818b1c3fa839cef077cad5c6767bda41e6737e31ed44ed1a3fd6a97` | +| stored_art_provider | `0x2e3011cf968bbea8b72e75efdfe120318ccf61fe711d2f7f927114e2d8da56e` | +| beast_png_regular_data | `0x15d5742d2e7804531ac456b7ba82e9dc961ba154cbaaad631e2e7b4e887b68b` | +| beast_png_shiny_data | `0x3a1bfcae2737a12df248675d57c3a1a94eeceb5a696f14fa6f23fd99bf3d247` | +| beast_gif_regular_data | `0x1597fbb34f42f6b49fa2944943c74396ff7bdec4028743e02290b7c6714c900` | +| beast_gif_shiny_data | `0x44f9108354ce2a7699348ae13cb6c2dc595c8eff342c936bd9e0f3c79252939` | + +`stored_art_provider` is declared but never deployed directly — the registry +deploys one instance per species with `salt = beast_id`. + +## Wiring + +Both pointers are write-once and both are required; a stack missing either +accepts no community species at all. + +``` +registry.set_nft_address(0x01dac778...) tx 0x06a0634be77503b1e32507406b046d320098f2f17a0142cc56dba07639e22fd5 +nft.set_registry_address(0x06d46c98...) tx 0x02af8798debaea3fbd5949bafe2b506e048a8fed8cde08101e63149fa4236ded +``` + +`dungeon_address` is **unset (zero)**, so genesis species 1–75 cannot be +minted yet. That is deliberate: it is also the state mainnet must launch in +until the `burn_and_mint` migration completes, or a dungeon could claim an +`(id, prefix, suffix)` slot a V2 holder still needs. + +## Verification performed + +| Check | Result | +|---|---| +| Constructor genesis mint | `total_supply() == 75` | +| Genesis token ownership | `owner_of(0x7006400010000000000000000001)` → deployer | +| Genesis render (legacy art path) | `token_uri` → `"Warlock"`, new bestiary description | +| Permissionless registration | `register_beast_with_art('Gloomfang', Hunter, 3, ...)` → species **76** | +| Factory art provider auto-deploy | `0x4b11caad7b2b29949f957c0854d2fa3a37fc40519bfee2eb844d8f319ac19b9`, `factory_provider: true` | +| Provenance mint | `total_supply()` 75 → 76 | +| Per-species mint auth | `mint(..., 76, 1, 1, 10, 100, 0, 1)` from the registered minter succeeded | +| Community render | `"Agony Bane" Gloomfang`, Rank 1, 20,970-byte SVG | + +Decoded attributes of the community mint — every value routed correctly: + +``` +Beast ID 76 Beast Gloomfang Type Hunter Tier 3 (from registry) +Prefix Agony Suffix Bane (shared tables) +Level 10 Health 100 Power 30 Rank 1 +Shiny 0 Animated 1 Genesis 0 +Adventurers Killed 0 Last Killed By 0 Last Death Timestamp 0 +``` + +`animated = 1` selected the GIF variant (`R0lGODdh...`) from the factory +provider, confirming variant routing. Power 30 = level 10 × (6 − tier 3). +Stats are zero because no `stats_source` is set — the cache is read, never a +live call. + +## Not done here + +- **Registering the original 75 into the registry** — dropped from scope. + Species 1–75 resolve name/tier/type from `beast_definitions` and render + through the legacy art contracts without any registry entry. See the + design doc; a backfill remains possible later. +- `set_dungeon_address` — left zero, see above. +- `set_death_mountain_address` — zero; genesis stats read as 0. + +## Reproducing + +Declares must be run **one at a time with `--wait`**. Firing them +back-to-back produces `Invalid transaction nonce`, because sncast reads the +account nonce before the previous declare is accepted. diff --git a/src/art_validation.cairo b/src/art_validation.cairo new file mode 100644 index 0000000..16e8126 --- /dev/null +++ b/src/art_validation.cairo @@ -0,0 +1,178 @@ +//! Render-time validation of art returned by community art providers. +//! +//! A community species' `IBeastArtProvider` is an arbitrary artist-controlled +//! contract, so `token_uri` cannot trust what it returns. The returned string +//! is embedded verbatim inside a single-quoted `src='...'` attribute of the +//! generated SVG, which means an unvalidated provider could close the +//! attribute and inject markup into every token of its species — including +//! tokens held by people who never dealt with that artist. +//! +//! Requiring an exact media-type prefix from a fixed allowlist plus a strict +//! base64 body makes that impossible: no quote, angle bracket, or whitespace +//! can survive the charset check. Only structure is checked here. Whether the +//! payload decodes to a *good* image is the artist's problem; whether it can +//! break the document is ours. +//! +//! The factory provider (`stored_art_provider`) validates more strictly still +//! — it also verifies PNG/GIF magic bytes at write time — because it carries +//! the trusted "verified art" designation. This module is the weaker floor +//! that every provider, custom ones included, must clear at render time. + +/// Media types a community provider may return. SVG is allowed because it is +/// consumed through an `` element, where user agents render it in a +/// restricted mode with scripting disabled. +pub fn media_prefix_len(uri: @ByteArray) -> u32 { + let candidates: Array = array![ + "data:image/png;base64,", "data:image/gif;base64,", "data:image/webp;base64,", + "data:image/svg+xml;base64,", + ]; + + let mut i = 0; + let mut found: u32 = 0; + let len = candidates.len(); + while i < len { + let candidate = candidates.at(i); + if starts_with(uri, candidate) { + found = candidate.len(); + break; + } + i += 1; + } + found +} + +/// Panics unless `uri` is an allowlisted image data URI with a structurally +/// valid standard-base64 payload. Deliberately imposes no size cap: if an +/// artist is willing to pay for the storage and the network accepts the +/// transaction, the art is valid. +pub fn assert_valid_render_uri(uri: @ByteArray) { + let prefix_len = media_prefix_len(uri); + assert(prefix_len != 0, 'Art: bad media type'); + + let total_len = uri.len(); + let payload_len = total_len - prefix_len; + assert(payload_len >= 4, 'Art: empty payload'); + assert(payload_len % 4 == 0, 'Art: bad payload length'); + + // '=' padding may only occupy the final two bytes, and "=X" is never a + // legal tail. + let mut i = prefix_len; + while i < total_len - 2 { + assert(is_base64_char(uri.at(i).unwrap()), 'Art: bad payload'); + i += 1; + } + + let second_last = uri.at(total_len - 2).unwrap(); + let last = uri.at(total_len - 1).unwrap(); + assert(is_base64_char(second_last) || second_last == '=', 'Art: bad payload'); + assert(is_base64_char(last) || last == '=', 'Art: bad payload'); + if second_last == '=' { + assert(last == '=', 'Art: bad payload'); + } +} + +fn starts_with(uri: @ByteArray, needle: @ByteArray) -> bool { + let needle_len = needle.len(); + if uri.len() < needle_len { + return false; + } + + let mut i = 0; + let mut matched = true; + while i < needle_len { + if uri.at(i).unwrap() != needle.at(i).unwrap() { + matched = false; + break; + } + i += 1; + } + matched +} + +/// Strict base64 alphabet, excluding padding. +pub fn is_base64_char(byte: u8) -> bool { + (byte >= 'A' && byte <= 'Z') + || (byte >= 'a' && byte <= 'z') + || (byte >= '0' && byte <= '9') + || byte == '+' + || byte == '/' +} + +#[cfg(test)] +mod tests { + use super::{assert_valid_render_uri, media_prefix_len}; + + #[test] + fn test_accepts_each_allowed_media_type() { + assert_valid_render_uri(@"data:image/png;base64,iVBORw0KGgo="); + assert_valid_render_uri(@"data:image/gif;base64,R0lGODlhAQAB"); + assert_valid_render_uri(@"data:image/webp;base64,UklGRhIAAABX"); + assert_valid_render_uri(@"data:image/svg+xml;base64,PHN2Zy8+"); + } + + #[test] + fn test_media_prefix_len() { + assert(media_prefix_len(@"data:image/png;base64,AAAA") == 22, 'png prefix len'); + assert(media_prefix_len(@"data:image/svg+xml;base64,AAAA") == 26, 'svg prefix len'); + assert(media_prefix_len(@"data:text/html;base64,AAAA") == 0, 'html not allowed'); + } + + #[test] + #[should_panic(expected: 'Art: bad media type')] + fn test_rejects_html_media_type() { + assert_valid_render_uri(@"data:text/html;base64,PHNjcmlwdD4="); + } + + #[test] + #[should_panic(expected: 'Art: bad media type')] + fn test_rejects_non_base64_data_uri() { + // URL-encoded SVG carries raw markup; only base64 payloads are safe + // to interpolate into the single-quoted attribute. + assert_valid_render_uri(@"data:image/svg+xml,"); + } + + #[test] + #[should_panic(expected: 'Art: bad media type')] + fn test_rejects_leading_whitespace() { + assert_valid_render_uri(@" data:image/png;base64,iVBORw0KGgo="); + } + + #[test] + #[should_panic(expected: 'Art: bad payload')] + fn test_rejects_attribute_escape() { + // The exact attack the validator exists to stop: a quote would close + // the src='...' attribute. Payload length stays 4-aligned so this + // exercises the charset check, not the length check. + assert_valid_render_uri(@"data:image/png;base64,AAAA'AAA"); + } + + #[test] + #[should_panic(expected: 'Art: bad payload')] + fn test_rejects_angle_bracket() { + assert_valid_render_uri(@"data:image/png;base64,AAAAAAA"); + } + + #[test] + #[should_panic(expected: 'Art: bad payload length')] + fn test_rejects_unaligned_payload() { + assert_valid_render_uri(@"data:image/png;base64,AAAAA"); + } + + #[test] + #[should_panic(expected: 'Art: empty payload')] + fn test_rejects_empty_payload() { + assert_valid_render_uri(@"data:image/png;base64,"); + } + + #[test] + #[should_panic(expected: 'Art: bad payload')] + fn test_rejects_interior_padding() { + assert_valid_render_uri(@"data:image/png;base64,AA=ABBBB"); + } + + #[test] + #[should_panic(expected: 'Art: bad payload')] + fn test_rejects_padding_followed_by_char() { + assert_valid_render_uri(@"data:image/png;base64,AAAAAA=A"); + } +} diff --git a/src/beast_manager.cairo b/src/beast_manager.cairo index b329374..0bb3c62 100644 --- a/src/beast_manager.cairo +++ b/src/beast_manager.cairo @@ -1,6 +1,10 @@ use super::beast_definitions; use super::pack::{PackableBeast, get_hash}; +/// Highest species ID backed by the baked-in `beast_definitions` tables. +/// Community species registered through `beast_registry` start at 76. +pub const GENESIS_SPECIES_MAX: u64 = 75; + /// Result type for beast operations #[derive(Drop, Copy, Serde, PartialEq)] pub enum BeastResult { @@ -14,17 +18,38 @@ pub struct BeastManager {} #[generate_trait] pub impl BeastManagerImpl of BeastManagerTrait { - /// Validates a beast ID is within valid range. - /// Genesis species tables cover 1-75; registered community species - /// extend this range once the registry lands. + /// Validates a beast ID is structurally usable. Species *existence* is + /// resolved by the contract, not here: `beast_definitions` answers for + /// genesis species 1-75, the registry for community species 76+. Zero is + /// never a species, and `decode_token_id` rejects it too. fn validate_beast_id(beast_id: u64) -> BeastResult<()> { - if beast_id >= 1 && beast_id <= 75 { + if beast_id >= 1 { BeastResult::Ok(()) } else { BeastResult::Err('Invalid beast ID') } } + /// True for the 75 species baked into `beast_definitions`. Community + /// species live above this line and resolve through the registry. + fn is_genesis_species(beast_id: u64) -> bool { + beast_id >= 1 && beast_id <= GENESIS_SPECIES_MAX + } + + /// Validates contract-resolved species traits. These are never + /// caller-supplied, but a registry read is still external input to the + /// NFT, and `encode_token_id` would silently truncate out-of-range + /// values into a different beast. + fn validate_species_traits(tier: u8, beast_type: u8) -> BeastResult<()> { + if tier == 0 || tier > 5 { + return BeastResult::Err('Invalid tier'); + } + if beast_type > 2 { + return BeastResult::Err('Invalid beast type'); + } + BeastResult::Ok(()) + } + /// Validates beast attributes are within valid ranges. /// The (id, 0, 0) affix slot is reserved for the species' Genesis Beast, /// so regular mints require both prefix and suffix >= 1. @@ -54,42 +79,74 @@ pub impl BeastManagerImpl of BeastManagerTrait { BeastResult::Ok(()) } - /// Creates a new beast with validation. - /// Tier and type are resolved by the contract, never caller-supplied, - /// so the values encoded into the token ID are trustworthy. - fn create_beast( - beast_id: u64, prefix: u8, suffix: u8, level: u16, health: u16, shiny: u8, animated: u8, + /// Creates a new beast from caller-supplied attributes plus species + /// traits the contract has already resolved (tables for genesis species, + /// registry for community species). Tier and type are never + /// caller-supplied, so the values encoded into the token ID are + /// trustworthy. + fn create_beast_with_traits( + beast_id: u64, + prefix: u8, + suffix: u8, + level: u16, + health: u16, + shiny: u8, + animated: u8, + tier: u8, + beast_type: u8, ) -> BeastResult { - // Validate beast ID match Self::validate_beast_id(beast_id) { BeastResult::Ok(_) => {}, BeastResult::Err(e) => { return BeastResult::Err(e); }, } - // Validate attributes match Self::validate_beast_attributes(prefix, suffix, shiny, animated) { BeastResult::Ok(_) => {}, BeastResult::Err(e) => { return BeastResult::Err(e); }, } - let (tier, beast_type) = Self::resolve_species_traits(beast_id); + match Self::validate_species_traits(tier, beast_type) { + BeastResult::Ok(_) => {}, + BeastResult::Err(e) => { return BeastResult::Err(e); }, + } - // Create the beast let beast = PackableBeast { id: beast_id, prefix, suffix, level, health, shiny, animated, tier, beast_type, }; BeastResult::Ok(beast) } - /// Creates a genesis beast with default attributes - fn create_genesis_beast(beast_id: u64) -> BeastResult { - // Validate beast ID + /// Genesis-species convenience wrapper: resolves tier/type from + /// `beast_definitions`. Rejects community species, which have no table + /// entry. + fn create_beast( + beast_id: u64, prefix: u8, suffix: u8, level: u16, health: u16, shiny: u8, animated: u8, + ) -> BeastResult { + if !Self::is_genesis_species(beast_id) { + return BeastResult::Err('Invalid beast ID'); + } + + let (tier, beast_type) = Self::resolve_species_traits(beast_id); + Self::create_beast_with_traits( + beast_id, prefix, suffix, level, health, shiny, animated, tier, beast_type, + ) + } + + /// Creates a Genesis Beast — the (id, 0, 0) affix slot reserved as the + /// artist/creator token — from contract-resolved species traits. + fn create_genesis_beast_with_traits( + beast_id: u64, tier: u8, beast_type: u8, + ) -> BeastResult { match Self::validate_beast_id(beast_id) { BeastResult::Ok(_) => {}, BeastResult::Err(e) => { return BeastResult::Err(e); }, } - let (tier, beast_type) = Self::resolve_species_traits(beast_id); + match Self::validate_species_traits(tier, beast_type) { + BeastResult::Ok(_) => {}, + BeastResult::Err(e) => { return BeastResult::Err(e); }, + } + BeastResult::Ok(Self::genesis_beast(beast_id, tier, beast_type)) } @@ -114,23 +171,40 @@ pub impl BeastManagerImpl of BeastManagerTrait { } } - /// Resolves the static tier/type for a species. - /// Only genesis species (1-75) exist until the registry lands; callers - /// must have validated the ID first. + /// Genesis-species convenience wrapper for the constructor batch. + fn create_genesis_beast(beast_id: u64) -> BeastResult { + if !Self::is_genesis_species(beast_id) { + return BeastResult::Err('Invalid beast ID'); + } + + let (tier, beast_type) = Self::resolve_species_traits(beast_id); + Self::create_genesis_beast_with_traits(beast_id, tier, beast_type) + } + + /// Resolves the static tier/type for a genesis species from the baked-in + /// tables. Community species (76+) resolve through the registry instead; + /// callers must have checked `is_genesis_species` first. fn resolve_species_traits(beast_id: u64) -> (u8, u8) { let species: u8 = beast_id.try_into().expect('not a genesis species'); (beast_definitions::get_tier(species), beast_definitions::get_type_code(species)) } + /// Species display name for a genesis species. Community species names + /// live in the registry. + fn resolve_species_name(beast_id: u64) -> felt252 { + let species: u8 = beast_id.try_into().expect('not a genesis species'); + beast_definitions::get_beast_name(species) + } + /// Generates a unique hash for a beast combination fn get_beast_hash(beast_id: u64, prefix: u8, suffix: u8) -> felt252 { get_hash(beast_id, prefix, suffix) } - /// Gets the beast name including prefix and suffix - fn get_full_beast_name(beast: PackableBeast) -> (felt252, felt252, felt252) { - let species: u8 = beast.id.try_into().expect('not a genesis species'); - let base_name = beast_definitions::get_beast_name(species); + /// Affix names for a beast. Prefix and suffix tables are shared by every + /// species, genesis and community alike, so this stays a pure lookup. + /// Zero means "no affix", which only the Genesis Beast has. + fn get_affix_names(beast: PackableBeast) -> (felt252, felt252) { let prefix_name = if beast.prefix > 0 { beast_definitions::get_prefix(beast.prefix) } else { @@ -142,6 +216,16 @@ pub impl BeastManagerImpl of BeastManagerTrait { 0 }; + (prefix_name, suffix_name) + } + + /// Full name of a genesis-species beast. Community species need the + /// registry for the base name, so the renderer resolves it separately and + /// pairs it with `get_affix_names`. + fn get_full_beast_name(beast: PackableBeast) -> (felt252, felt252, felt252) { + let base_name = Self::resolve_species_name(beast.id); + let (prefix_name, suffix_name) = Self::get_affix_names(beast); + (prefix_name, base_name, suffix_name) } @@ -208,14 +292,98 @@ mod tests { BeastManagerTrait::validate_beast_id(0) == BeastResult::Err('Invalid beast ID'), 'ID 0 should be invalid', ); + } + + #[test] + fn test_validate_beast_id_accepts_community_range() { + // Existence of a community species is the registry's call, not this + // function's — it only rejects structurally impossible IDs. + assert(BeastManagerTrait::validate_beast_id(76) == BeastResult::Ok(()), 'ID 76 accepted'); + assert( + BeastManagerTrait::validate_beast_id(1_000_000) == BeastResult::Ok(()), + 'Large ID accepted', + ); + } + + #[test] + fn test_is_genesis_species() { + assert(BeastManagerTrait::is_genesis_species(1), 'ID 1 is genesis'); + assert(BeastManagerTrait::is_genesis_species(75), 'ID 75 is genesis'); + assert(!BeastManagerTrait::is_genesis_species(76), 'ID 76 is community'); + assert(!BeastManagerTrait::is_genesis_species(0), 'ID 0 is not a species'); + } + + #[test] + fn test_validate_species_traits() { + assert( + BeastManagerTrait::validate_species_traits(1, 0) == BeastResult::Ok(()), + 'T1 Magic valid', + ); assert( - BeastManagerTrait::validate_beast_id(76) == BeastResult::Err('Invalid beast ID'), - 'ID 76 should be invalid', + BeastManagerTrait::validate_species_traits(5, 2) == BeastResult::Ok(()), + 'T5 Brute valid', ); assert( - BeastManagerTrait::validate_beast_id(255) == BeastResult::Err('Invalid beast ID'), - 'ID 255 should be invalid', + BeastManagerTrait::validate_species_traits(0, 0) == BeastResult::Err('Invalid tier'), + 'Tier 0 invalid', ); + assert( + BeastManagerTrait::validate_species_traits(6, 0) == BeastResult::Err('Invalid tier'), + 'Tier 6 invalid', + ); + assert( + BeastManagerTrait::validate_species_traits( + 1, 3, + ) == BeastResult::Err('Invalid beast type'), + 'Type 3 invalid', + ); + } + + #[test] + fn test_create_beast_with_traits_community_species() { + // Community species carry registry-supplied traits and never touch + // the genesis tables. + match BeastManagerTrait::create_beast_with_traits(9_000, 4, 7, 30, 500, 1, 1, 2, 1) { + BeastResult::Ok(beast) => { + assert(beast.id == 9_000, 'Beast ID mismatch'); + assert(beast.tier == 2, 'Tier mismatch'); + assert(beast.beast_type == 1, 'Type mismatch'); + }, + BeastResult::Err(_) => { assert(false, 'Should not fail'); }, + } + } + + #[test] + fn test_create_beast_with_traits_rejects_bad_tier() { + match BeastManagerTrait::create_beast_with_traits(9_000, 4, 7, 30, 500, 0, 0, 9, 1) { + BeastResult::Ok(_) => { assert(false, 'Should fail'); }, + BeastResult::Err(e) => { assert(e == 'Invalid tier', 'Wrong error'); }, + } + } + + #[test] + fn test_create_beast_rejects_community_species() { + // The genesis wrapper has no table entry for 76+. + match BeastManagerTrait::create_beast(76, 1, 1, 10, 100, 0, 0) { + BeastResult::Ok(_) => { assert(false, 'Should fail'); }, + BeastResult::Err(e) => { assert(e == 'Invalid beast ID', 'Wrong error'); }, + } + } + + #[test] + fn test_create_genesis_beast_with_traits() { + match BeastManagerTrait::create_genesis_beast_with_traits(500, 3, 2) { + BeastResult::Ok(beast) => { + assert(beast.id == 500, 'Beast ID mismatch'); + assert(beast.prefix == 0, 'Prefix should be 0'); + assert(beast.suffix == 0, 'Suffix should be 0'); + assert(beast.shiny == 1, 'Shiny should be 1'); + assert(beast.animated == 1, 'Animated should be 1'); + assert(beast.tier == 3, 'Tier mismatch'); + assert(beast.beast_type == 2, 'Type mismatch'); + }, + BeastResult::Err(_) => { assert(false, 'Should not fail'); }, + } } #[test] diff --git a/src/beast_svg.cairo b/src/beast_svg.cairo index 2b6e182..030af6f 100644 --- a/src/beast_svg.cairo +++ b/src/beast_svg.cairo @@ -1,19 +1,24 @@ use core::byte_array::ByteArrayTrait; use super::beast_manager::BeastAttributes; -use super::interfaces::{IBeastImageDataProviderDispatcher, IBeastImageDataProviderDispatcherTrait}; use super::utils::felt252_to_byte_array; #[generate_trait] pub impl BeastSvgImpl of BeastSvgTrait { - /// Generates a complete data URI for the SVG + /// Generates a complete data URI for the SVG. + /// + /// `beast_image` is a pre-fetched image data URI. The contract resolves + /// and validates it before calling: genesis species read from the four + /// deployed art data contracts, community species from their registered + /// `IBeastArtProvider`. Keeping the fetch out of the renderer is what + /// lets a single code path serve both, and keeps the untrusted-provider + /// validation at the boundary where it belongs. fn generate_svg( - beast_id: u64, prefix_name: felt252, suffix_name: felt252, beast_name: felt252, rank: u16, beast_attrs: BeastAttributes, - image_data_provider: IBeastImageDataProviderDispatcher, + beast_image: ByteArray, ) -> ByteArray { let is_shiny = beast_attrs.shiny > 0; @@ -225,10 +230,6 @@ pub impl BeastSvgImpl of BeastSvgTrait { .append( @" { fn get_dungeon_address(self: @TContractState) -> ContractAddress; fn set_death_mountain_address(ref self: TContractState, death_mountain: ContractAddress); fn get_death_mountain_address(self: @TContractState) -> ContractAddress; + fn set_registry_address(ref self: TContractState, registry: ContractAddress); + fn get_registry_address(self: @TContractState) -> ContractAddress; // Minting functions fn mint( @@ -43,6 +45,11 @@ pub trait IBeasts { // Metadata functions fn refresh_metadata(ref self: TContractState, beast_id: u64); fn refresh_dungeon_stats(ref self: TContractState, token_id: u256); + /// Permissionless pull of a community species token's live stats into the + /// on-chain cache that `token_uri` reads. Genesis species keep their live + /// Death Mountain reads and are rejected here. + fn refresh_stats(ref self: TContractState, token_id: u256); + fn get_cached_stats(self: @TContractState, token_id: u256) -> BeastLiveStats; // Beast queries fn get_beast(self: @TContractState, token_id: u256) -> PackableBeast; diff --git a/src/lib.cairo b/src/lib.cairo index 384f43f..4d5fd17 100644 --- a/src/lib.cairo +++ b/src/lib.cairo @@ -1,3 +1,4 @@ +pub mod art_validation; pub mod beast_definitions; pub mod beast_gif_regular_data; pub mod beast_gif_shiny_data; @@ -20,6 +21,9 @@ pub mod metadata_generator; mod mint_tests; pub mod minting_coordinator; pub mod pack; +#[cfg(test)] +mod registry_integration_tests; +pub mod stats_cache; pub mod stored_art_provider; #[cfg(test)] mod tests; @@ -44,16 +48,26 @@ pub mod beasts_nft { use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use super::beast_manager::{BeastManagerTrait, BeastResult}; + use super::art_validation::assert_valid_render_uri; + use super::beast_manager::{BeastManagerTrait, BeastResult, GENESIS_SPECIES_MAX}; use super::beast_ranking::BeastRankingManagerTrait; use super::enumerable::EnumerableComponent; use super::interfaces::{ - IBeastImageDataProviderDispatcher, IBeastSystemsDispatcher, IBeastSystemsDispatcherTrait, - IBeasts, IBeastsAnimation, + BeastLiveStats, IBeastArtProviderDispatcher, IBeastArtProviderDispatcherTrait, + IBeastImageDataProviderDispatcher, IBeastImageDataProviderDispatcherTrait, + IBeastRegistryDispatcher, IBeastRegistryDispatcherTrait, IBeastStatsDispatcher, + IBeastStatsDispatcherTrait, IBeastSystemsDispatcher, IBeastSystemsDispatcherTrait, IBeasts, + IBeastsAnimation, IBeastsProvenance, }; use super::metadata_generator::MetadataGeneratorTrait; use super::minting_coordinator::{MintRequest, MintingCoordinatorTrait}; use super::pack::{PackableBeast, decode_token_id}; + use super::stats_cache::CachedStats; + + /// Per-transaction cap on ERC-4906 fan-out. A species tops out at 1,243 + /// tokens, so a full refresh can exceed one block's budget; the overflow + /// is bookmarked and drained by `refresh_metadata`. + const FAN_OUT_LIMIT: u16 = 650; component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); component!(path: ERC721Component, storage: erc721, event: ERC721Event); @@ -133,6 +147,13 @@ pub mod beasts_nft { pub minted: Map, pub dungeon_address: ContractAddress, pub supply_count: u256, + /// Permissionless species registry. Community species (76+) resolve + /// their minter, traits, name, art provider, and stats source here. + pub registry: IBeastRegistryDispatcher, + /// Community-species stats, pulled by `refresh_stats` rather than read + /// live, so an artist-nominated stats source can never brick + /// `token_uri`. See `stats_cache`. + pub cached_stats: Map, // External data providers pub regular_png_provider: IBeastImageDataProviderDispatcher, pub shiny_png_provider: IBeastImageDataProviderDispatcher, @@ -237,6 +258,22 @@ pub mod beasts_nft { self.death_mountain_dispatcher.read().contract_address } + /// Wires the permissionless species registry. Write-once: the + /// registry is the sole authority over who may mint every community + /// species, and it holds a matching one-way pointer back here, so a + /// later swap would orphan every registered species and let a new + /// registry mint into their reserved affix slots. + fn set_registry_address(ref self: ContractState, registry: ContractAddress) { + self.ownable.assert_only_owner(); + assert(self.registry.read().contract_address.is_zero(), 'Registry already set'); + assert(registry.is_non_zero(), 'Zero registry'); + self.registry.write(IBeastRegistryDispatcher { contract_address: registry }); + } + + fn get_registry_address(self: @ContractState) -> ContractAddress { + self.registry.read().contract_address + } + fn mint( ref self: ContractState, to: ContractAddress, @@ -248,15 +285,17 @@ pub mod beasts_nft { shiny: u8, animated: u8, ) -> (u256, u16, bool) { - // Ensure caller is Dungeon - let caller = starknet::get_caller_address(); - assert(caller == self.dungeon_address.read(), 'Not authorized to mint'); + // Authorize the caller and resolve the species' static traits. + // Genesis species answer to the single dungeon address; every + // community species has its own minter in the registry. + let (tier, beast_type) = InternalTrait::assert_can_mint(@self, beast_id); // Prepare mint request let request = MintRequest { beast_id, prefix, suffix, level, health, shiny, animated }; // Validate and prepare mint data - let (token_id, insertion_rank) = match MintingCoordinatorTrait::prepare_mint(request) { + let (token_id, insertion_rank) = + match MintingCoordinatorTrait::prepare_mint_with_traits(request, tier, beast_type) { BeastResult::Ok(mint_data) => { // Check for duplicates assert(!self.minted.entry(mint_data.hash).read(), 'Beast already minted'); @@ -356,6 +395,50 @@ pub mod beasts_nft { .write(starknet::get_block_timestamp()); } + /// Pulls a community species token's live stats into the cache that + /// `token_uri` reads. Permissionless — anyone may keep a token fresh + /// — but it is the *only* place the artist-nominated stats source is + /// called. If that source reverts, this transaction fails and + /// rendering carries on with the last cached values. + fn refresh_stats(ref self: ContractState, token_id: u256) { + self.erc721._require_owned(token_id); + let beast = decode_token_id(token_id); + assert(beast.id > GENESIS_SPECIES_MAX, 'Genesis stats are live'); + + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + let source = registry.get_stats_source(beast.id); + assert(source.is_non_zero(), 'No stats source'); + + let beast_hash = BeastManagerTrait::get_beast_hash( + beast.id, beast.prefix, beast.suffix, + ); + let live = IBeastStatsDispatcher { contract_address: source } + .get_beast_stats(beast_hash); + let fresh = CachedStats { + adventurers_killed: live.adventurers_killed, + last_killed_by: live.last_killed_by, + last_killed_timestamp: live.last_killed_timestamp, + }; + + // Only announce a change that actually happened; without this the + // call is a free ERC-4906 spam faucet against every indexer. + let cached = self.cached_stats.entry(token_id).read(); + assert(fresh != cached, 'Stats up to date'); + + self.cached_stats.entry(token_id).write(fresh); + self.emit(MetadataUpdate { token_id }); + } + + fn get_cached_stats(self: @ContractState, token_id: u256) -> BeastLiveStats { + let cached = self.cached_stats.entry(token_id).read(); + BeastLiveStats { + adventurers_killed: cached.adventurers_killed, + last_killed_by: cached.last_killed_by, + last_killed_timestamp: cached.last_killed_timestamp, + } + } + fn get_beast(self: @ContractState, token_id: u256) -> PackableBeast { self.erc721._require_owned(token_id); decode_token_id(token_id) @@ -499,9 +582,185 @@ pub mod beasts_nft { } } + // Registry-only entrypoints. The registry is the permissionless surface; + // these are the two things it needs the NFT to do on a registrant's + // behalf. + #[abi(embed_v0)] + impl BeastsProvenanceImpl of IBeastsProvenance { + /// Mints the species' Genesis Beast — the (id, 0, 0) affix slot, + /// permanently reserved as the artist/creator token — as the final + /// step of registration. + /// + /// Uses `erc721.mint`, not `safe_mint`: `safe_mint` calls back into + /// the recipient, which would hand a contract artist a reentry point + /// into the registry mid-registration, while its definition is + /// written but before `next_id` has settled. + fn mint_provenance(ref self: ContractState, artist: ContractAddress, beast_id: u64) { + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + assert(starknet::get_caller_address() == registry.contract_address, 'Only registry'); + // Genesis species were minted in the constructor; the registry + // must never be able to re-issue one. + assert(beast_id > GENESIS_SPECIES_MAX, 'Not a community species'); + + let (tier, beast_type) = registry.get_species_traits(beast_id); + + match MintingCoordinatorTrait::prepare_genesis_mint_with_traits( + beast_id, tier, beast_type, + ) { + BeastResult::Ok(mint_data) => { + assert(!self.minted.entry(mint_data.hash).read(), 'Beast already minted'); + self.minted.entry(mint_data.hash).write(true); + + self.erc721.mint(artist, mint_data.token_id); + self.supply_count.write(self.supply_count.read() + 1); + }, + BeastResult::Err(e) => { core::panic_with_felt252(e); }, + } + } + + /// Fans out ERC-4906 events for a species after its art changed. + fn emit_species_metadata_update(ref self: ContractState, beast_id: u64) { + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + assert(starknet::get_caller_address() == registry.contract_address, 'Only registry'); + + InternalTrait::emit_species_fan_out(ref self, beast_id); + } + } + // Internal implementations #[generate_trait] impl InternalImpl of InternalTrait { + /// Authorizes the caller to mint `beast_id` and returns the species' + /// static (tier, type). + /// + /// Genesis species answer to the single owner-set dungeon address. + /// Community species each name their own minter in the registry — a + /// zero minter means the species is paused, and is rejected rather + /// than matched against a zero caller. + fn assert_can_mint(self: @ContractState, beast_id: u64) -> (u8, u8) { + // Structural check first: zero is never a species, and without + // this it would fall through to the registry branch and report a + // misleading wiring error. + match BeastManagerTrait::validate_beast_id(beast_id) { + BeastResult::Ok(_) => {}, + BeastResult::Err(e) => { core::panic_with_felt252(e); }, + } + + let caller = starknet::get_caller_address(); + + if BeastManagerTrait::is_genesis_species(beast_id) { + assert(caller == self.dungeon_address.read(), 'Not authorized to mint'); + return BeastManagerTrait::resolve_species_traits(beast_id); + } + + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + // Reverts for an unregistered species, so an unknown ID can never + // be minted with attacker-chosen traits. + let (tier, beast_type) = registry.get_species_traits(beast_id); + + let minter = registry.get_minter(beast_id); + assert(minter.is_non_zero(), 'Species minting paused'); + assert(caller == minter, 'Not authorized to mint'); + + (tier, beast_type) + } + + /// Species display name: baked-in tables for genesis species, the + /// registry for community species. + fn resolve_species_name(self: @ContractState, beast_id: u64) -> felt252 { + if BeastManagerTrait::is_genesis_species(beast_id) { + return BeastManagerTrait::resolve_species_name(beast_id); + } + + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + registry.get_species_name(beast_id) + } + + /// Resolves a beast's image data URI. + /// + /// Genesis species read from the four art data contracts wired at + /// construction. Community species call their registered + /// `IBeastArtProvider` with the full decoded beast, so a provider can + /// vary art by affix, tier, or level — and whatever it returns is + /// validated before it reaches the SVG, because that provider is an + /// arbitrary artist-controlled contract. + fn resolve_art(self: @ContractState, beast: PackableBeast) -> ByteArray { + if BeastManagerTrait::is_genesis_species(beast.id) { + let provider = if beast.animated == 0 { + if beast.shiny == 1 { + self.shiny_png_provider.read() + } else { + self.regular_png_provider.read() + } + } else { + if beast.shiny == 1 { + self.shiny_gif_provider.read() + } else { + self.regular_gif_provider.read() + } + }; + let legacy_species: u8 = beast.id.try_into().expect('not a genesis species'); + return provider.get_data_uri(legacy_species); + } + + let registry = self.registry.read(); + assert(registry.contract_address.is_non_zero(), 'Registry not set'); + let art_provider = registry.get_art_provider(beast.id); + assert(art_provider.is_non_zero(), 'No art provider'); + + let uri = IBeastArtProviderDispatcher { contract_address: art_provider } + .get_data_uri(beast); + assert_valid_render_uri(@uri); + uri + } + + /// Emits `MetadataUpdate` for every token of a species, bookmarking + /// the overflow past `FAN_OUT_LIMIT` for `refresh_metadata` to drain. + fn emit_species_fan_out(ref self: ContractState, beast_id: u64) { + // The Genesis Beast holds rank 0 and is deliberately absent from + // `beast_species_lists`, so a list walk alone would leave the + // artist's own token stale after every art change. + let genesis_hash = BeastManagerTrait::get_beast_hash(beast_id, 0, 0); + if self.minted.entry(genesis_hash).read() { + // Tier and type are part of the token ID, so they must be the + // species' real ones — resolve rather than assume. + let (tier, beast_type) = Self::resolve_traits_for_fan_out(@self, beast_id); + match MintingCoordinatorTrait::prepare_genesis_mint_with_traits( + beast_id, tier, beast_type, + ) { + BeastResult::Ok(mint_data) => { + self.emit(MetadataUpdate { token_id: mint_data.token_id }); + }, + BeastResult::Err(e) => { core::panic_with_felt252(e); }, + } + } + + let total_beasts = self.beast_counts.entry(beast_id).read(); + let last = if total_beasts > FAN_OUT_LIMIT { + self.beast_metadata_refresh_bookmark.entry(beast_id).write(FAN_OUT_LIMIT + 1); + FAN_OUT_LIMIT + } else { + total_beasts + }; + + let mut rank: u16 = 1; + while rank <= last { + let token_id = self.beast_species_lists.entry(beast_id).entry(rank).read(); + self.emit(MetadataUpdate { token_id }); + rank += 1; + } + } + + fn resolve_traits_for_fan_out(self: @ContractState, beast_id: u64) -> (u8, u8) { + if BeastManagerTrait::is_genesis_species(beast_id) { + return BeastManagerTrait::resolve_species_traits(beast_id); + } + self.registry.read().get_species_traits(beast_id) + } /// Internal function to mint genesis beasts during contract construction fn mint_genesis_beasts(ref self: ContractState, to: ContractAddress) { // Prepare genesis batch @@ -547,55 +806,59 @@ pub mod beasts_nft { let beast = decode_token_id(token_id); let rank = BeastRankingManagerTrait::get_beast_rank(self, token_id); - // Get additional data from death mountain + // Combat stats. Genesis species read Death Mountain live — that + // dispatcher is owner-set and trusted. Community species read the + // cache instead: their stats source is artist-nominated, and a + // failed external call cannot be caught on Starknet, so a live + // read here would let any artist permanently brick rendering for + // their whole species. let mut last_killed_timestamp = 0; let mut last_killed_by_adventurer = 0; let mut adventurers_killed = 0; - let death_mountain_dispatcher = self.death_mountain_dispatcher.read(); - if death_mountain_dispatcher.contract_address != Zero::zero() { - let death_mountain_address = self.dungeon_address.read(); - if death_mountain_address != Zero::zero() { - let beast_hash = BeastManagerTrait::get_beast_hash( - beast.id, beast.prefix, beast.suffix, - ); - let num_deaths = death_mountain_dispatcher - .get_collectable_count( - death_mountain_dispatcher.contract_address, beast_hash, + if BeastManagerTrait::is_genesis_species(beast.id) { + let death_mountain_dispatcher = self.death_mountain_dispatcher.read(); + if death_mountain_dispatcher.contract_address != Zero::zero() { + let death_mountain_address = self.dungeon_address.read(); + if death_mountain_address != Zero::zero() { + let beast_hash = BeastManagerTrait::get_beast_hash( + beast.id, beast.prefix, beast.suffix, ); - if num_deaths > 0 { - let collectable_entity = death_mountain_dispatcher - .get_collectable(death_mountain_address, beast_hash, num_deaths - 1); - last_killed_timestamp = collectable_entity.timestamp; - last_killed_by_adventurer = collectable_entity.killed_by; + let num_deaths = death_mountain_dispatcher + .get_collectable_count( + death_mountain_dispatcher.contract_address, beast_hash, + ); + if num_deaths > 0 { + let collectable_entity = death_mountain_dispatcher + .get_collectable( + death_mountain_address, beast_hash, num_deaths - 1, + ); + last_killed_timestamp = collectable_entity.timestamp; + last_killed_by_adventurer = collectable_entity.killed_by; + } + + let entity_stats = death_mountain_dispatcher + .get_entity_stats(death_mountain_address, beast_hash); + + adventurers_killed = entity_stats.adventurers_killed; } - - let entity_stats = death_mountain_dispatcher - .get_entity_stats(death_mountain_address, beast_hash); - - adventurers_killed = entity_stats.adventurers_killed; - } - } - - // Choose image provider based on beast flags - let mut image_data_provider = self.regular_gif_provider.read(); - if beast.animated == 0 { - if beast.shiny == 1 { - image_data_provider = self.shiny_png_provider.read(); - } else { - image_data_provider = self.regular_png_provider.read(); } } else { - if beast.shiny == 1 { - image_data_provider = self.shiny_gif_provider.read(); - } + let cached = self.cached_stats.entry(token_id).read(); + adventurers_killed = cached.adventurers_killed; + last_killed_by_adventurer = cached.last_killed_by; + last_killed_timestamp = cached.last_killed_timestamp; } + let beast_name = Self::resolve_species_name(self, beast.id); + let beast_image = Self::resolve_art(self, beast); + // Generate metadata MetadataGeneratorTrait::generate_metadata( token_id, beast, rank, - image_data_provider, + beast_name, + beast_image, adventurers_killed, last_killed_by_adventurer, last_killed_timestamp, @@ -607,13 +870,13 @@ pub mod beasts_nft { ) -> bool { let total_beasts = self.beast_counts.entry(beast_id).read(); let mut bookmark_set = false; - if total_beasts > 650 { + if total_beasts > FAN_OUT_LIMIT { let distance_to_last = total_beasts - insertion_rank; - if distance_to_last >= 650 { + if distance_to_last >= FAN_OUT_LIMIT { self .beast_metadata_refresh_bookmark .entry(beast_id) - .write(insertion_rank + 650); + .write(insertion_rank + FAN_OUT_LIMIT); bookmark_set = true; } } @@ -622,7 +885,7 @@ pub mod beasts_nft { // 650 let mut count = insertion_rank + 1; while count < total_beasts { - if count >= insertion_rank + 650 { + if count >= insertion_rank + FAN_OUT_LIMIT { break; } let token_id = self.beast_species_lists.entry(beast_id).entry(count).read(); diff --git a/src/metadata_generator.cairo b/src/metadata_generator.cairo index f687d07..8003e0b 100644 --- a/src/metadata_generator.cairo +++ b/src/metadata_generator.cairo @@ -1,7 +1,6 @@ -use super::beast_manager::BeastManagerTrait; +use super::beast_manager::{BeastManagerTrait, GENESIS_SPECIES_MAX}; use super::beast_svg::BeastSvgTrait; use super::encoding::bytes_base64_encode; -use super::interfaces::IBeastImageDataProviderDispatcher; use super::pack::PackableBeast; use super::utils::felt252_to_byte_array; @@ -27,12 +26,17 @@ pub struct Attribute { #[generate_trait] pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { - /// Generates complete metadata JSON for a beast + /// Generates complete metadata JSON for a beast. + /// + /// `beast_name` and `beast_image` are resolved by the contract, which is + /// the only place that knows whether a species comes from the baked-in + /// genesis tables or from the registry. fn generate_metadata( token_id: u256, beast: PackableBeast, rank: u16, - image_data_provider: IBeastImageDataProviderDispatcher, + beast_name: felt252, + beast_image: ByteArray, adventurers_killed: u64, last_killed_by_adventurer: u64, last_killed_timestamp: u64, @@ -41,7 +45,8 @@ pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { token_id, beast, rank, - image_data_provider, + beast_name, + beast_image, adventurers_killed, last_killed_by_adventurer, last_killed_timestamp, @@ -56,7 +61,8 @@ pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { token_id: u256, beast: PackableBeast, rank: u16, - image_data_provider: IBeastImageDataProviderDispatcher, + beast_name: felt252, + beast_image: ByteArray, adventurers_killed: u64, last_killed_by_adventurer: u64, last_killed_timestamp: u64, @@ -81,14 +87,15 @@ pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { .append( @"\\n\\nFor collectors, an ever-growing bestiary with verifiable scarcity, issuance, and provenance. For players, endless and timeless opportunities for onchain fun.", ); - if beast.id <= 75 { + if beast.id <= GENESIS_SPECIES_MAX { description .append( @"\\n\\nArtwork for the original 75 species courtesy of the legends at 1337 Skulls (:5ku11u73:)", ); } - // Get beast names - let (prefix_name, beast_name, suffix_name) = BeastManagerTrait::get_full_beast_name(beast); + // Affix names are shared tables; the species name was resolved by the + // contract (genesis tables or registry) and passed in. + let (prefix_name, suffix_name) = BeastManagerTrait::get_affix_names(beast); // Build name let mut name: ByteArray = ""; @@ -113,7 +120,7 @@ pub impl MetadataGeneratorImpl of MetadataGeneratorTrait { // Image let svg = BeastSvgTrait::generate_svg( - beast.id, prefix_name, suffix_name, beast_name, rank, beast_attrs, image_data_provider, + prefix_name, suffix_name, beast_name, rank, beast_attrs, beast_image, ); let image = format!("data:image/svg+xml;base64,{}", bytes_base64_encode(svg)); @@ -440,20 +447,15 @@ mod tests { let last_killed_by_adventurer = 1002; let last_killed_timestamp = 1715558400; - let mock_data_provider_address = 'data_provider'.try_into().unwrap(); - let mock_return_data: ByteArray = + let beast_image: ByteArray = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAAXNSR0IArs4c6QAAASFJREFUSIm1VW2uxCAIVE/dI+yt3/vBBu0wfLVZstlQhAFGxTF+IH+frc+HYVc1aj1Al0+wgKeuVjugcGkfXhGJX1r+KT2K0oqkp6KzW1cjst5BXdrlQ6TV35ZDsYJ9BiEUaXAKUTlL01rn9dVVGfcjD7hwGyBkqVX9VKekx23ZkHVmO3PQYDqOaEO6OsHppMV+gsiq/ivUaVlevaM8MhURsoosMckvQN8tM7vNsR28isAyzOmie+CluWE9uKUSRW9P6T0ABnawuRy26sa4tuxXHtG8A0rxqcRSpYiia9ZXCbwNuKH4OUp78Gb6NyhyIUKK8g7s+QH0OH2VIjoqFD0aiCk0iXGqpo327kEXfaQUwQa4I/Nyh1iDIvumDsYhtPIPgYPBCOPyCoAAAAAASUVORK5CYII="; - start_mock_call(mock_data_provider_address, selector!("get_data_uri"), mock_return_data); - - let beast_image_data_provider_dispatcher = IBeastImageDataProviderDispatcher { - contract_address: mock_data_provider_address, - }; let components = MetadataGeneratorTrait::build_metadata_components( 123, beast, 1, - beast_image_data_provider_dispatcher, + 'Typhon', + beast_image, adventurers_killed, last_killed_by_adventurer, last_killed_timestamp, @@ -594,12 +596,11 @@ mod tests { get_regular_png_provider() } }; - let image_data_provider = IBeastImageDataProviderDispatcher { - contract_address: provider_addr, - }; + let beast_image = IBeastImageDataProviderDispatcher { contract_address: provider_addr } + .get_data_uri(beast_id); BeastSvgTrait::generate_svg( - beast.id, prefix_name, suffix_name, beast_name, rank, beast_attrs, image_data_provider, + prefix_name, suffix_name, beast_name, rank, beast_attrs, beast_image, ) } diff --git a/src/mint_tests.cairo b/src/mint_tests.cairo index 27de434..3807a1b 100644 --- a/src/mint_tests.cairo +++ b/src/mint_tests.cairo @@ -191,8 +191,12 @@ mod mint_tests { } #[test] - #[should_panic(expected: ('Invalid beast ID',))] - fn test_mint_invalid_beast_id_too_high() { + #[should_panic(expected: ('Registry not set',))] + fn test_mint_community_species_without_registry() { + // IDs above 75 are community species: they resolve their minter and + // traits through the registry, so without one wired there is nothing + // to authorize against and the mint must fail closed. The dungeon + // address governs genesis species only and grants nothing here. let (beasts, _, _, _, owner) = deploy_contract(); let minter = test_address('minter'); @@ -201,7 +205,7 @@ mod mint_tests { stop_cheat_caller_address(beasts.contract_address); start_cheat_caller_address(beasts.contract_address, minter); - beasts.mint(minter, 76, 0, 0, 1, 100, 0, 0); + beasts.mint(minter, 76, 1, 1, 1, 100, 0, 0); stop_cheat_caller_address(beasts.contract_address); } diff --git a/src/minting_coordinator.cairo b/src/minting_coordinator.cairo index a959ee5..722c230 100644 --- a/src/minting_coordinator.cairo +++ b/src/minting_coordinator.cairo @@ -27,10 +27,12 @@ pub struct MintingCoordinator {} #[generate_trait] pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { - /// Validates and prepares data for minting - fn prepare_mint(request: MintRequest) -> BeastResult { - // Create and validate the beast - match BeastManagerTrait::create_beast( + /// Validates and prepares data for minting, given species traits the + /// contract has already resolved (genesis tables or registry). + fn prepare_mint_with_traits( + request: MintRequest, tier: u8, beast_type: u8, + ) -> BeastResult { + match BeastManagerTrait::create_beast_with_traits( request.beast_id, request.prefix, request.suffix, @@ -38,6 +40,8 @@ pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { request.health, request.shiny, request.animated, + tier, + beast_type, ) { BeastResult::Ok(beast) => { // Generate hash for uniqueness checking @@ -54,10 +58,22 @@ pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { } } - /// Prepares data for genesis mint - fn prepare_genesis_mint(beast_id: u64) -> BeastResult { - // Create genesis beast - match BeastManagerTrait::create_genesis_beast(beast_id) { + /// Genesis-species convenience wrapper: resolves traits from the tables. + fn prepare_mint(request: MintRequest) -> BeastResult { + if !BeastManagerTrait::is_genesis_species(request.beast_id) { + return BeastResult::Err('Invalid beast ID'); + } + + let (tier, beast_type) = BeastManagerTrait::resolve_species_traits(request.beast_id); + Self::prepare_mint_with_traits(request, tier, beast_type) + } + + /// Prepares the Genesis Beast mint for a species, given resolved traits. + /// Used for the registry's provenance mint of community species. + fn prepare_genesis_mint_with_traits( + beast_id: u64, tier: u8, beast_type: u8, + ) -> BeastResult { + match BeastManagerTrait::create_genesis_beast_with_traits(beast_id, tier, beast_type) { BeastResult::Ok(beast) => { // Genesis beasts have no prefix/suffix, so hash is simpler let hash = BeastManagerTrait::get_beast_hash(beast_id, 0, 0); @@ -69,6 +85,16 @@ pub impl MintingCoordinatorImpl of MintingCoordinatorTrait { } } + /// Genesis-species convenience wrapper for the constructor batch. + fn prepare_genesis_mint(beast_id: u64) -> BeastResult { + if !BeastManagerTrait::is_genesis_species(beast_id) { + return BeastResult::Err('Invalid beast ID'); + } + + let (tier, beast_type) = BeastManagerTrait::resolve_species_traits(beast_id); + Self::prepare_genesis_mint_with_traits(beast_id, tier, beast_type) + } + /// Prepares batch genesis mint data fn prepare_genesis_batch() -> Array> { let mut results = array![]; diff --git a/src/registry_integration_tests.cairo b/src/registry_integration_tests.cairo new file mode 100644 index 0000000..e9f6518 --- /dev/null +++ b/src/registry_integration_tests.cairo @@ -0,0 +1,642 @@ +//! End-to-end tests across the real NFT and the real registry. +//! +//! `beast_registry_tests` exercises the registry against a mock NFT; this +//! file wires the two production contracts together, which is the only place +//! the round trip is proven: register -> provenance mint -> species mint -> +//! render. + +/// Community art provider that returns whatever it was constructed with, +/// including deliberately malformed payloads. Stands in for the arbitrary +/// artist-controlled contract that `register_beast` accepts. +#[starknet::contract] +pub mod mock_art_provider { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use super::super::interfaces::IBeastArtProvider; + use super::super::pack::PackableBeast; + + #[storage] + struct Storage { + uri: ByteArray, + } + + #[constructor] + fn constructor(ref self: ContractState, uri: ByteArray) { + self.uri.write(uri); + } + + #[abi(embed_v0)] + impl BeastArtProviderImpl of IBeastArtProvider { + fn get_data_uri(self: @ContractState, beast: PackableBeast) -> ByteArray { + self.uri.read() + } + } +} + +/// SRC5-compliant stats source with settable values, so a refresh can be +/// observed changing and then going stale. +#[starknet::contract] +pub mod mock_stats_feed { + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use super::super::interfaces::{BeastLiveStats, IBEAST_STATS_ID, IBeastStats}; + + #[starknet::interface] + pub trait IMockStatsAdmin { + fn set_stats(ref self: TContractState, killed: u64, by: u64, ts: u64); + fn supports_interface(self: @TContractState, interface_id: felt252) -> bool; + } + + #[storage] + struct Storage { + killed: u64, + by: u64, + ts: u64, + } + + #[abi(embed_v0)] + impl BeastStatsImpl of IBeastStats { + fn get_beast_stats(self: @ContractState, entity_hash: felt252) -> BeastLiveStats { + BeastLiveStats { + adventurers_killed: self.killed.read(), + last_killed_by: self.by.read(), + last_killed_timestamp: self.ts.read(), + } + } + } + + #[abi(embed_v0)] + impl MockStatsAdminImpl of IMockStatsAdmin { + fn set_stats(ref self: ContractState, killed: u64, by: u64, ts: u64) { + self.killed.write(killed); + self.by.write(by); + self.ts.write(ts); + } + + fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { + interface_id == IBEAST_STATS_ID + } + } +} + +#[cfg(test)] +mod tests { + use beasts_nft::interfaces::{ + BeastType, IBeastRegistryDispatcher, IBeastRegistryDispatcherTrait, IBeastsDispatcher, + IBeastsDispatcherTrait, IBeastsOwnerEnumerableDispatcher, + IBeastsOwnerEnumerableDispatcherTrait, IBeastsProvenanceDispatcher, + IBeastsProvenanceDispatcherTrait, + }; + use openzeppelin_interfaces::erc721::{ + IERC721Dispatcher, IERC721DispatcherTrait, IERC721MetadataDispatcher, + IERC721MetadataDispatcherTrait, + }; + use snforge_std::{ + ContractClassTrait, DeclareResultTrait, EventSpyTrait, EventsFilterTrait, declare, + spy_events, start_cheat_caller_address, start_mock_call, stop_cheat_caller_address, + }; + use starknet::ContractAddress; + use super::mock_stats_feed::{IMockStatsAdminDispatcher, IMockStatsAdminDispatcherTrait}; + + const FIRST_COMMUNITY_ID: u64 = 76; + + fn test_address(address: felt252) -> ContractAddress { + address.try_into().unwrap() + } + + fn zero_address() -> ContractAddress { + 0.try_into().unwrap() + } + + 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", + ) + } + + #[derive(Drop, Copy)] + struct Stack { + nft: IBeastsDispatcher, + registry: IBeastRegistryDispatcher, + owner: ContractAddress, + } + + /// Deploys the real registry and the real NFT and wires the two one-way + /// pointers that make registration possible. + fn setup() -> Stack { + let owner = test_address('owner'); + + // The four genesis art contracts are out of scope here; one mocked + // address serves all of them. + let legacy_provider = test_address('legacy_art'); + let legacy_uri: ByteArray = "data:image/png;base64,iVBORw0KGgoAAAA1"; + start_mock_call(legacy_provider, selector!("get_data_uri"), legacy_uri); + + let provider_class = declare("stored_art_provider").unwrap().contract_class(); + let registry_class = declare("beast_registry").unwrap().contract_class(); + let nft_class = declare("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 name: ByteArray = "Beasts"; + let symbol: ByteArray = "BEAST"; + let mut nft_calldata: Array = array![]; + name.serialize(ref nft_calldata); + symbol.serialize(ref nft_calldata); + owner.serialize(ref nft_calldata); + owner.serialize(ref nft_calldata); + 500_u128.serialize(ref nft_calldata); + legacy_provider.serialize(ref nft_calldata); + legacy_provider.serialize(ref nft_calldata); + legacy_provider.serialize(ref nft_calldata); + legacy_provider.serialize(ref nft_calldata); + zero_address().serialize(ref nft_calldata); + let (nft_address, _) = nft_class.deploy(@nft_calldata).unwrap(); + + let registry = IBeastRegistryDispatcher { contract_address: registry_address }; + let nft = IBeastsDispatcher { contract_address: nft_address }; + + start_cheat_caller_address(registry_address, owner); + registry.set_nft_address(nft_address); + stop_cheat_caller_address(registry_address); + + start_cheat_caller_address(nft_address, owner); + nft.set_registry_address(registry_address); + stop_cheat_caller_address(nft_address); + + Stack { nft, registry, owner } + } + + fn register_with_factory_art( + stack: @Stack, artist: ContractAddress, minter: ContractAddress, + ) -> u64 { + let (png_regular, png_shiny, gif_regular, gif_shiny) = sample_art(); + start_cheat_caller_address(*stack.registry.contract_address, artist); + let beast_id = (*stack.registry) + .register_beast_with_art( + 'Gloomfang', + BeastType::Hunter, + 3, + minter, + png_regular, + png_shiny, + gif_regular, + gif_shiny, + ); + stop_cheat_caller_address(*stack.registry.contract_address); + beast_id + } + + fn deploy_art_provider(uri: ByteArray) -> ContractAddress { + let class = declare("mock_art_provider").unwrap().contract_class(); + let mut calldata: Array = array![]; + uri.serialize(ref calldata); + let (address, _) = class.deploy(@calldata).unwrap(); + address + } + + fn register_with_custom_provider( + stack: @Stack, artist: ContractAddress, minter: ContractAddress, provider: ContractAddress, + ) -> u64 { + start_cheat_caller_address(*stack.registry.contract_address, artist); + let beast_id = (*stack.registry) + .register_beast('Gloomfang', BeastType::Hunter, 3, minter, provider); + stop_cheat_caller_address(*stack.registry.contract_address); + beast_id + } + + fn mint_community( + stack: @Stack, minter: ContractAddress, to: ContractAddress, beast_id: u64, prefix: u8, + ) -> u256 { + start_cheat_caller_address(*stack.nft.contract_address, minter); + let (token_id, _, _) = (*stack.nft).mint(to, beast_id, prefix, 1, 10, 100, 0, 0); + stop_cheat_caller_address(*stack.nft.contract_address); + token_id + } + + // ---------------- wiring ---------------- + + #[test] + fn test_registry_wiring_is_visible_both_ways() { + let stack = setup(); + assert( + stack.nft.get_registry_address() == stack.registry.contract_address, + 'NFT points at registry', + ); + assert( + stack.registry.get_nft_address() == stack.nft.contract_address, + 'Registry points at NFT', + ); + } + + #[test] + #[should_panic(expected: ('Registry already set',))] + fn test_set_registry_address_is_one_time() { + let stack = setup(); + start_cheat_caller_address(stack.nft.contract_address, stack.owner); + stack.nft.set_registry_address(test_address('other_registry')); + stop_cheat_caller_address(stack.nft.contract_address); + } + + #[test] + #[should_panic(expected: ('Caller is not the owner',))] + fn test_set_registry_address_only_owner() { + let stack = setup(); + start_cheat_caller_address(stack.nft.contract_address, test_address('intruder')); + stack.nft.set_registry_address(test_address('other_registry')); + stop_cheat_caller_address(stack.nft.contract_address); + } + + // ---------------- registration -> provenance mint ---------------- + + #[test] + fn test_registration_mints_genesis_to_artist() { + let stack = setup(); + let artist = test_address('artist'); + let beast_id = register_with_factory_art(@stack, artist, test_address('dungeon')); + + assert(beast_id == FIRST_COMMUNITY_ID, 'First community ID is 76'); + // 75 genesis species from the constructor plus this one. + assert(stack.nft.total_supply() == 76, 'Supply grew by one'); + assert(stack.nft.is_minted(beast_id, 0, 0), 'Genesis slot reserved'); + + // The Genesis Beast is the artist's provenance token. + let genesis_token = stack.nft.get_token_id_at_rank(beast_id, 0); + assert(genesis_token == 0, 'Genesis is not ranked'); + + let erc721 = IERC721Dispatcher { contract_address: stack.nft.contract_address }; + assert(erc721.balance_of(artist) == 1, 'Artist holds provenance token'); + } + + #[test] + fn test_registered_species_renders_through_registry_art() { + let stack = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + let beast_id = register_with_factory_art(@stack, artist, minter); + + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + + let metadata = IERC721MetadataDispatcher { contract_address: stack.nft.contract_address }; + let uri = metadata.token_uri(token_id); + assert(uri.len() > 0, 'Renders non-empty metadata'); + + // Rendering must go through the base64 JSON envelope like any token. + let prefix: ByteArray = "data:application/json;base64,"; + let mut i = 0; + while i < prefix.len() { + assert(uri.at(i).unwrap() == prefix.at(i).unwrap(), 'JSON data URI prefix'); + i += 1; + } + } + + #[test] + #[should_panic(expected: ('Only registry', 'ENTRYPOINT_FAILED'))] + fn test_mint_provenance_only_callable_by_registry() { + let stack = setup(); + let provenance = IBeastsProvenanceDispatcher { + contract_address: stack.nft.contract_address, + }; + start_cheat_caller_address(stack.nft.contract_address, test_address('intruder')); + provenance.mint_provenance(test_address('intruder'), 76); + stop_cheat_caller_address(stack.nft.contract_address); + } + + #[test] + #[should_panic(expected: ('Only registry', 'ENTRYPOINT_FAILED'))] + fn test_emit_species_metadata_update_only_callable_by_registry() { + let stack = setup(); + let provenance = IBeastsProvenanceDispatcher { + contract_address: stack.nft.contract_address, + }; + start_cheat_caller_address(stack.nft.contract_address, test_address('intruder')); + provenance.emit_species_metadata_update(76); + stop_cheat_caller_address(stack.nft.contract_address); + } + + // ---------------- the Genesis Beast is the artist role ---------------- + + #[test] + fn test_selling_the_genesis_beast_hands_over_the_species() { + // Against the real NFT, not a mock: the registry asks it who holds + // the creator token, so an ordinary ERC721 transfer — a marketplace + // sale — has to move control of the species with it. + let stack = setup(); + let artist = test_address('artist'); + let buyer = test_address('buyer'); + let beast_id = register_with_factory_art(@stack, artist, test_address('dungeon')); + + let genesis = stack.registry.get_genesis_token_id(beast_id); + assert(stack.registry.get_artist(beast_id) == artist, 'Registrant is the artist'); + + let erc721 = IERC721Dispatcher { contract_address: stack.nft.contract_address }; + start_cheat_caller_address(erc721.contract_address, artist); + erc721.transfer_from(artist, buyer, genesis); + stop_cheat_caller_address(erc721.contract_address); + + assert(stack.registry.get_artist(beast_id) == buyer, 'Buyer is now the artist'); + + start_cheat_caller_address(stack.registry.contract_address, buyer); + stack.registry.set_minter(beast_id, test_address('their_dungeon')); + stop_cheat_caller_address(stack.registry.contract_address); + assert( + stack.registry.get_minter(beast_id) == test_address('their_dungeon'), + 'Buyer can administer', + ); + } + + #[test] + #[should_panic(expected: ('Registry: not artist', 'ENTRYPOINT_FAILED'))] + fn test_seller_loses_control_with_the_token() { + let stack = setup(); + let artist = test_address('artist'); + let beast_id = register_with_factory_art(@stack, artist, test_address('dungeon')); + + let erc721 = IERC721Dispatcher { contract_address: stack.nft.contract_address }; + start_cheat_caller_address(erc721.contract_address, artist); + erc721 + .transfer_from( + artist, test_address('buyer'), stack.registry.get_genesis_token_id(beast_id), + ); + stop_cheat_caller_address(erc721.contract_address); + + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.set_minter(beast_id, test_address('their_dungeon')); + } + + #[test] + fn test_enumeration_finds_the_species_a_wallet_controls() { + // The whole point of pairing enumeration with the derived role: a + // client can list a wallet's tokens, decode each one locally, and + // know which species it administers — no registry reads, no events. + let stack = setup(); + let artist = test_address('artist'); + let first = register_with_factory_art(@stack, artist, test_address('dungeon')); + + let enumerable = IBeastsOwnerEnumerableDispatcher { + contract_address: stack.nft.contract_address, + }; + let erc721 = IERC721Dispatcher { contract_address: stack.nft.contract_address }; + + assert(erc721.balance_of(artist) == 1, 'Artist holds one token'); + let token = enumerable.token_of_owner_by_index(artist, 0); + assert(token == stack.registry.get_genesis_token_id(first), 'Enumerates the genesis'); + + // Decoding it locally recovers the species with no further reads. + let beast = stack.nft.get_beast(token); + assert(beast.id == first, 'Species recovered from token'); + assert(beast.prefix == 0 && beast.suffix == 0, 'It is the Genesis Beast'); + assert(stack.registry.get_artist(beast.id) == artist, 'And the wallet controls it'); + } + + // ---------------- per-species mint authorization ---------------- + + #[test] + fn test_species_minter_can_mint() { + let stack = setup(); + let minter = test_address('dungeon'); + let player = test_address('player'); + let beast_id = register_with_factory_art(@stack, test_address('artist'), minter); + + let token_id = mint_community(@stack, minter, player, beast_id, 1); + + let erc721 = IERC721Dispatcher { contract_address: stack.nft.contract_address }; + assert(erc721.owner_of(token_id) == player, 'Player owns the mint'); + + let beast = stack.nft.get_beast(token_id); + assert(beast.id == beast_id, 'Species encoded in token'); + assert(beast.tier == 3, 'Registry tier encoded'); + assert(beast.beast_type == 1, 'Registry type encoded'); + } + + #[test] + #[should_panic(expected: ('Not authorized to mint',))] + fn test_dungeon_address_cannot_mint_community_species() { + // The global dungeon address governs genesis species only. Community + // species answer to their own registered minter. + let stack = setup(); + let dungeon = test_address('global_dungeon'); + start_cheat_caller_address(stack.nft.contract_address, stack.owner); + stack.nft.set_dungeon_address(dungeon); + stop_cheat_caller_address(stack.nft.contract_address); + + let beast_id = register_with_factory_art( + @stack, test_address('artist'), test_address('species_minter'), + ); + + mint_community(@stack, dungeon, test_address('player'), beast_id, 1); + } + + #[test] + #[should_panic(expected: ('Species minting paused',))] + fn test_zero_minter_species_cannot_be_minted() { + let stack = setup(); + let beast_id = register_with_factory_art(@stack, test_address('artist'), zero_address()); + + mint_community(@stack, zero_address(), test_address('player'), beast_id, 1); + } + + #[test] + #[should_panic( + expected: ('Registry: not registered', 'ENTRYPOINT_FAILED', 'ENTRYPOINT_FAILED'), + )] + fn test_unregistered_species_cannot_be_minted() { + let stack = setup(); + mint_community(@stack, test_address('anyone'), test_address('player'), 9_999, 1); + } + + #[test] + fn test_minter_change_moves_authorization() { + let stack = setup(); + let artist = test_address('artist'); + let first_minter = test_address('first'); + let second_minter = test_address('second'); + let beast_id = register_with_factory_art(@stack, artist, first_minter); + + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.set_minter(beast_id, second_minter); + stop_cheat_caller_address(stack.registry.contract_address); + + let token_id = mint_community(@stack, second_minter, test_address('player'), beast_id, 1); + assert(token_id != 0, 'New minter can mint'); + } + + // ---------------- untrusted art provider output ---------------- + + #[test] + #[should_panic(expected: ('Art: bad payload',))] + fn test_provider_attribute_escape_rejected_at_render() { + // A custom provider is an arbitrary contract; the payload below would + // close the SVG's src='...' attribute if it were embedded verbatim. + let stack = setup(); + let minter = test_address('dungeon'); + let provider = deploy_art_provider("data:image/png;base64,AAAA'AAA"); + let beast_id = register_with_custom_provider( + @stack, test_address('artist'), minter, provider, + ); + + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + let metadata = IERC721MetadataDispatcher { contract_address: stack.nft.contract_address }; + metadata.token_uri(token_id); + } + + #[test] + #[should_panic(expected: ('Art: bad media type',))] + fn test_provider_non_image_media_type_rejected_at_render() { + let stack = setup(); + let minter = test_address('dungeon'); + let provider = deploy_art_provider("data:text/html;base64,PHNjcmlwdD4="); + let beast_id = register_with_custom_provider( + @stack, test_address('artist'), minter, provider, + ); + + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + let metadata = IERC721MetadataDispatcher { contract_address: stack.nft.contract_address }; + metadata.token_uri(token_id); + } + + #[test] + fn test_provider_svg_output_accepted_at_render() { + let stack = setup(); + let minter = test_address('dungeon'); + let provider = deploy_art_provider("data:image/svg+xml;base64,PHN2Zy8+"); + let beast_id = register_with_custom_provider( + @stack, test_address('artist'), minter, provider, + ); + + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + let metadata = IERC721MetadataDispatcher { contract_address: stack.nft.contract_address }; + assert(metadata.token_uri(token_id).len() > 0, 'SVG art renders'); + } + + // ---------------- cached stats ---------------- + + fn deploy_stats_source() -> IMockStatsAdminDispatcher { + let class = declare("mock_stats_feed").unwrap().contract_class(); + let (address, _) = class.deploy(@array![]).unwrap(); + IMockStatsAdminDispatcher { contract_address: address } + } + + #[test] + fn test_refresh_stats_caches_values() { + let stack = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + let beast_id = register_with_factory_art(@stack, artist, minter); + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + + let source = deploy_stats_source(); + source.set_stats(7, 1234, 1_700_000_000); + + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.set_stats_source(beast_id, source.contract_address); + stop_cheat_caller_address(stack.registry.contract_address); + + let before = stack.nft.get_cached_stats(token_id); + assert(before.adventurers_killed == 0, 'Cache starts empty'); + + stack.nft.refresh_stats(token_id); + + let after = stack.nft.get_cached_stats(token_id); + assert(after.adventurers_killed == 7, 'Kills cached'); + assert(after.last_killed_by == 1234, 'Killer cached'); + assert(after.last_killed_timestamp == 1_700_000_000, 'Timestamp cached'); + } + + #[test] + #[should_panic(expected: ('Stats up to date',))] + fn test_refresh_stats_rejects_unchanged_values() { + // Without this guard the entrypoint is a free ERC-4906 spam faucet. + let stack = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + let beast_id = register_with_factory_art(@stack, artist, minter); + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + + let source = deploy_stats_source(); + source.set_stats(7, 1234, 1_700_000_000); + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.set_stats_source(beast_id, source.contract_address); + stop_cheat_caller_address(stack.registry.contract_address); + + stack.nft.refresh_stats(token_id); + stack.nft.refresh_stats(token_id); + } + + #[test] + #[should_panic(expected: ('No stats source',))] + fn test_refresh_stats_requires_a_source() { + let stack = setup(); + let minter = test_address('dungeon'); + let beast_id = register_with_factory_art(@stack, test_address('artist'), minter); + let token_id = mint_community(@stack, minter, test_address('player'), beast_id, 1); + + stack.nft.refresh_stats(token_id); + } + + #[test] + #[should_panic(expected: ('Genesis stats are live',))] + fn test_refresh_stats_rejects_genesis_species() { + // Species 1-75 read Death Mountain live; there is no cache to fill. + let stack = setup(); + let dungeon = test_address('dungeon'); + start_cheat_caller_address(stack.nft.contract_address, stack.owner); + stack.nft.set_dungeon_address(dungeon); + stop_cheat_caller_address(stack.nft.contract_address); + + start_cheat_caller_address(stack.nft.contract_address, dungeon); + let (token_id, _, _) = stack.nft.mint(test_address('player'), 3, 1, 1, 10, 100, 0, 0); + stop_cheat_caller_address(stack.nft.contract_address); + + stack.nft.refresh_stats(token_id); + } + + // ---------------- metadata fan-out ---------------- + + #[test] + fn test_fan_out_covers_genesis_token_and_every_mint() { + let stack = setup(); + let artist = test_address('artist'); + let minter = test_address('dungeon'); + // A custom provider keeps the refresh path open after registration + // without needing to re-upload art. + let provider = deploy_art_provider("data:image/png;base64,iVBORw0KGgoAAAA1"); + let beast_id = register_with_custom_provider(@stack, artist, minter, provider); + + mint_community(@stack, minter, test_address('player1'), beast_id, 1); + mint_community(@stack, minter, test_address('player2'), beast_id, 2); + mint_community(@stack, minter, test_address('player3'), beast_id, 3); + + let mut spy = spy_events(); + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.notify_art_updated(beast_id); + stop_cheat_caller_address(stack.registry.contract_address); + + // The Genesis Beast holds rank 0 and lives outside the species list; + // a list-only walk would leave the artist's own token stale. + let emitted = spy.get_events().emitted_by(stack.nft.contract_address); + assert(emitted.events.len() == 4, 'Genesis plus three mints'); + } + + #[test] + fn test_fan_out_before_any_mint_covers_genesis_only() { + let stack = setup(); + let artist = test_address('artist'); + let provider = deploy_art_provider("data:image/png;base64,iVBORw0KGgoAAAA1"); + let beast_id = register_with_custom_provider( + @stack, artist, test_address('dungeon'), provider, + ); + + let mut spy = spy_events(); + start_cheat_caller_address(stack.registry.contract_address, artist); + stack.registry.notify_art_updated(beast_id); + stop_cheat_caller_address(stack.registry.contract_address); + + let emitted = spy.get_events().emitted_by(stack.nft.contract_address); + assert(emitted.events.len() == 1, 'Genesis token only'); + } +} diff --git a/src/stats_cache.cairo b/src/stats_cache.cairo new file mode 100644 index 0000000..f3b8f87 --- /dev/null +++ b/src/stats_cache.cairo @@ -0,0 +1,79 @@ +//! Cached combat stats for community species tokens. +//! +//! Community species draw live stats from an artist-nominated `IBeastStats` +//! source, which is an untrusted contract. Starknet cannot catch a failed +//! external call, so reading one from `token_uri` would let any artist brick +//! rendering for their entire species — permanently, since `lock_art` does +//! not freeze the stats pointer. Stats are therefore pulled by an explicit, +//! permissionless `refresh_stats` call and cached here; `token_uri` only ever +//! reads storage and can never revert because of a third-party contract. +//! +//! Genesis species (1-75) keep the original live Death Mountain reads: that +//! dispatcher is set by the contract owner, not by a permissionless caller. + +use starknet::storage_access::StorePacking; + +#[derive(Drop, Copy, Serde, PartialEq, Default)] +pub struct CachedStats { + pub adventurers_killed: u64, + pub last_killed_by: u64, + pub last_killed_timestamp: u64, +} + +const TWO_POW_64: u256 = 0x10000000000000000; +const TWO_POW_128: u256 = 0x100000000000000000000000000000000; + +/// Three u64s = 192 bits, comfortably inside one felt252 storage slot. +pub impl CachedStatsStorePacking of StorePacking { + fn pack(value: CachedStats) -> felt252 { + let packed: u256 = value.adventurers_killed.into() + + value.last_killed_by.into() * TWO_POW_64 + + value.last_killed_timestamp.into() * TWO_POW_128; + packed.try_into().expect('pack cached stats') + } + + fn unpack(value: felt252) -> CachedStats { + let mut packed: u256 = value.into(); + + let adventurers_killed: u64 = (packed % TWO_POW_64).try_into().expect('unpack killed'); + packed = packed / TWO_POW_64; + let last_killed_by: u64 = (packed % TWO_POW_64).try_into().expect('unpack killed by'); + packed = packed / TWO_POW_64; + let last_killed_timestamp: u64 = (packed % TWO_POW_64).try_into().expect('unpack ts'); + packed = packed / TWO_POW_64; + + assert(packed == 0, 'invalid cached stats'); + + CachedStats { adventurers_killed, last_killed_by, last_killed_timestamp } + } +} + +#[cfg(test)] +mod tests { + use super::{CachedStats, CachedStatsStorePacking}; + + #[test] + fn test_round_trip() { + let stats = CachedStats { + adventurers_killed: 42, last_killed_by: 1337, last_killed_timestamp: 1715558400, + }; + let unpacked = CachedStatsStorePacking::unpack(CachedStatsStorePacking::pack(stats)); + assert(unpacked == stats, 'stats round trip'); + } + + #[test] + fn test_round_trip_max_values() { + let max = 0xffffffffffffffff_u64; + let stats = CachedStats { + adventurers_killed: max, last_killed_by: max, last_killed_timestamp: max, + }; + let unpacked = CachedStatsStorePacking::unpack(CachedStatsStorePacking::pack(stats)); + assert(unpacked == stats, 'max round trip'); + } + + #[test] + fn test_zero_slot_decodes_to_default() { + let unpacked = CachedStatsStorePacking::unpack(0); + assert(unpacked == Default::default(), 'empty slot is default'); + } +}