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');
+ }
+}