diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 5518bf49..3c171b50 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -163,7 +163,7 @@ jobs: include: # Embeddable Game Standard - package: game_components_embeddable_game_standard - module: token + module: token_legacy runner: ubuntu-latest-32 fuzzer_runs: 32 - package: game_components_embeddable_game_standard @@ -178,6 +178,10 @@ jobs: module: registry runner: ubuntu-latest-8 fuzzer_runs: 32 + - package: game_components_embeddable_game_standard + module: token + runner: ubuntu-latest-8 + fuzzer_runs: 32 # Metagame - package: game_components_metagame module: leaderboard diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 1635bf4d..0b253f1e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -231,10 +231,11 @@ jobs: add() { INCLUDES=$(echo "$INCLUDES" | jq -c --arg p "$1" --arg m "$2" --arg r "$3" --argjson f "$4" '. + [{package:$p,module:$m,runner:$r,fuzzer_runs:$f}]'); } if [ "$NEED_EGS" = "true" ]; then - add game_components_embeddable_game_standard token ubuntu-latest-32 32 + add game_components_embeddable_game_standard token_legacy ubuntu-latest-32 32 add game_components_embeddable_game_standard minigame ubuntu-latest-8 32 add game_components_embeddable_game_standard metagame ubuntu-latest-8 32 add game_components_embeddable_game_standard registry ubuntu-latest-8 32 + add game_components_embeddable_game_standard token ubuntu-latest-8 32 fi if [ "$NEED_METAGAME" = "true" ]; then add game_components_metagame leaderboard ubuntu-latest-4 256 diff --git a/AGENTS.md b/AGENTS.md index 170d0123..5e833299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,8 @@ The workspace is organized into **group packages**, each containing multiple mod packages/ ├── embeddable_game_standard/ # Core game standard components │ ├── src/ -│ │ ├── token/ # ERC721 game token with compile-time feature flags +│ │ ├── token/ # THE minigame token standard (self-bound ERC721, absorbed minter) +│ │ ├── token_legacy/ # Original multi-game ERC721 token (kept for deployed denshokan) │ │ ├── minigame/ # Individual game logic foundation │ │ ├── metagame/ # High-level game coordination & context │ │ └── registry/ # Game registration and discovery @@ -79,8 +80,39 @@ Each module has its own `AGENTS.md` with detailed documentation inside its `src/ ## Architecture Overview +There are two token generations. New work targets the **standard** token; the +**legacy** token is kept for deployed denshokan. + +### Standard (`token/`) — self-bound, one address + +``` +Metagame ──→ Game contract (IS the ERC721 token) + │ ├── Settings (optional) + │ └── Objectives (optional) + └── Context (optional) +``` + +`MinigameTokenComponent` is embedded IN the game contract, so the game and the +token are the same address. There is no registry, no `game_address` resolution +and no mutable token state. + +**Game Lifecycle**: Setup → Mint → Play → `refresh_metadata()` (ERC-4906) + +There is no `update_game()` and no `IMetagameCallback`: nothing to sync. The +game contract is the authority on game-over and objective completion, gating +its own entrypoints with the component's internal `assert_owner_and_playable`. +Consumers identify a standard token by SRC5 (`IMINIGAME_TOKEN_ID`); the +creator/fee identity the registry used to hold lives on the token itself +(`IMINIGAME_TOKEN_CREATOR_ID`). + +`MinigameComponent` is **legacy-only** — it asserts `IMINIGAME_TOKEN_LEGACY_ID` +and calls `game_registry_address()`. A standard-token game embeds the token +component directly instead (see `test_common::mocks::standard_game_mock`). + +### Legacy (`token_legacy/`) — separate token contract, registry-backed + ``` -Metagame ──→ MinigameToken (ERC721) ──→ Minigame +Metagame ──→ MinigameTokenLegacy (ERC721) ──→ Minigame │ ▲ │ │ │ │ └── Registry ├── Settings (optional) │ │ └── Objectives (optional) @@ -92,6 +124,12 @@ Metagame ──→ MinigameToken (ERC721) ──→ Minigame When `update_game()` is called, the token checks if the minter implements `IMetagameCallback` (via SRC5) and dispatches score/game_over/objective callbacks automatically. +The `metagame` lib and `MetagameComponent` serve **both** generations, branching +on SRC5. `MetagameComponent` is itself **self-bound** — it stores no addresses +and exposes no ABI (`IMetagame`/`IMETAGAME_ID` were removed): the embedding +contract IS the metagame, each game's token is resolved per mint, and a +metagame that provides context embeds `ContextComponent` on its own address. + ## Key Patterns - `#[starknet::component]` for reusable architecture @@ -123,7 +161,7 @@ When adding a new module to a group package, update **both** files: fuzzer_runs: 256 ``` - For memory-intensive modules (like `token` or `minigame`), assign a larger runner (e.g., `ubuntu-latest-4` or `ubuntu-latest-32`). + For memory-intensive modules (like `token_legacy` or `minigame`), assign a larger runner (e.g., `ubuntu-latest-4` or `ubuntu-latest-32`). 2. **`codecov.yml`** - Update the build count: ```yaml @@ -131,20 +169,22 @@ When adding a new module to a group package, update **both** files: after_n_builds: 15 # ← Must equal total module count in matrix ``` -### Current Matrix (16 modules) +### Current Matrix (18 modules) | Group Package | Module | Runner | Fuzzer Runs | |---------------|--------|--------|-------------| -| `embeddable_game_standard` | `token` | `ubuntu-latest-32` | 32 | -| `embeddable_game_standard` | `minigame` | `ubuntu-latest-32` | 32 | -| `embeddable_game_standard` | `metagame` | `ubuntu-latest-32` | 256 | -| `embeddable_game_standard` | `registry` | `ubuntu-latest-32` | 256 | +| `embeddable_game_standard` | `token_legacy` | `ubuntu-latest-32` | 32 | +| `embeddable_game_standard` | `minigame` | `ubuntu-latest-8` | 32 | +| `embeddable_game_standard` | `metagame` | `ubuntu-latest-8` | 32 | +| `embeddable_game_standard` | `registry` | `ubuntu-latest-8` | 32 | +| `embeddable_game_standard` | `token` | `ubuntu-latest-8` | 32 | | `metagame` | `leaderboard` | `ubuntu-latest-4` | 256 | | `metagame` | `registration` | `ubuntu-latest-4` | 256 | | `metagame` | `entry_requirement` | `ubuntu-latest-4` | 256 | | `metagame` | `entry_fee` | `ubuntu-latest-4` | 256 | | `metagame` | `prize` | `ubuntu-latest-4` | 256 | | `metagame` | `ticket_booth` | `ubuntu-latest-4` | 256 | +| `metagame` | `merkledrop` | `ubuntu-latest-4` | 256 | | `economy` | `tokenomics` | `ubuntu-latest-4` | 256 | | `utilities` | `math` | `ubuntu-latest-4` | 256 | | `utilities` | `distribution` | `ubuntu-latest-4` | 256 | diff --git a/codecov.yml b/codecov.yml index 2bd75508..69ecbf57 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,9 +1,9 @@ codecov: require_ci_to_pass: true notify: - # Must equal package count in .github/workflows/main-ci.yml matrix - # See AGENTS.md "CI Configuration" section when adding packages - after_n_builds: 17 + # Must equal total module count in the .github/workflows/main-ci.yml matrix + # (modules, NOT packages). See AGENTS.md "CI Configuration" when adding a module + after_n_builds: 18 comment: layout: "diff, files, header, footer" diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md new file mode 100644 index 00000000..84782fc7 --- /dev/null +++ b/docs/denshokan-lite-migration.md @@ -0,0 +1,209 @@ +# Denshokan Lite → One-Address: the full change log and rationale + +*2026-08-06 — covers game-components [#123](https://github.com/Provable-Games/game-components/pull/123), super-death-mountain [#149](https://github.com/Provable-Games/super-death-mountain/pull/149) / [#150](https://github.com/Provable-Games/super-death-mountain/pull/150), budokan [#313](https://github.com/Provable-Games/budokan/pull/313). All numbers are measured — snforge harness or real Sepolia transactions — not estimates, except where marked.* + +## TL;DR + +A beast-mode game (~75 actions) cost **~$0.40** on the original architecture. On the final architecture it costs **~$0.35 (−12%)**, with every stage of a game's life cheaper: mint, start, every action, and game-end (which now costs *nothing* — the ~6.73M-gas sync transaction no longer exists). The dominant remaining cost is per-transaction protocol overhead (~44% of a long game), which points at client-side action batching (est. ~$0.25–0.28) as the next lever — a client change, not a contract change. + +Beyond gas: deployment went from 12 steps (including a baked-address fixed-point dance) to 3; subsystem upgrades became one owner storage write; the token can no longer desync from the game because they are the same contract; and the tournament layer (budokan) required **zero changes** to work with the final architecture — proven by live Sepolia tournaments. + +--- + +## The original architecture and where the gas went + +The original stack (mainnet today): + +``` +Player ──► Dungeons (greed, karat, …) ──mint──► Denshokan (shared multi-game ERC721) +Player ──► GameCore ──► 5 subsystem contracts │ │ + │ ▲ ▼ ▼ + │ └─(load_assets callbacks)◄── GameToken MinigameRegistry + └───guards/refresh/update_game──► Denshokan +``` + +Measured hot spots (SDM's own gas bench + mainnet observations): + +| Cost | Where | Why it existed | +|---|---|---| +| **~6.73M gas `update_game` subtree** | `start_game`, plus a keeper transaction per finished game | Denshokan pulled `game_over()`/`score()` from the game — each callback re-ran full `load_assets` (~1.56M each) — then re-validated SRC5, resolved the registry, probed the minter for metagame callbacks, and persisted a token-side game-over latch | +| **5 cross-contract calls per action** | every GameCore entrypoint | `owner_of` + `assert_is_playable` + `refresh_metadata` into denshokan, a settings read into GameToken, and the subsystem dispatch | +| **Registry + validation on every mint** | denshokan `mint` | SRC5 probe of the game, `game_id_from_address` registry lookup, cross-contract settings validation, minter-registry writes, enumerable index writes | + +The audits that preceded the changes established the key fact that made everything below safe: **the expensive machinery answered questions whose answers were constant or unused.** SDM used exactly one game (registry resolution always returned the same answer), no dungeon implemented the metagame callbacks (the SRC5 probe failed every time), the token-side game-over latch was only advisory (game logic already rejects dead adventurers on every path), and token-side context was write-only decoration (budokan keeps its own token→tournament map). + +--- + +## Phase 1 — the lite token (`game-components` #123) + +**What:** a new `token_lite` module: `CoreTokenLiteComponent`, a single-game ERC721 with *no mutable token state*. + +| Change | For | +|---|---| +| Registry removed; one `game_address` slot, bound once | Single-game deployments paid a registry round-trip on every mint and sync for an answer that never changed | +| `update_game`, the game-over/objective latch, and all metagame callbacks removed | The game contract is the sole authority on game-over; the latch was advisory and the callbacks were never consumed. `is_playable` becomes **zero storage reads** (lifecycle lives packed in the token id) | +| Objectives, context, skills, per-token renderer/client_url, enumerable, settings-validation-on-mint removed | Audits showed all unused by SDM; enumerable cost 2+ storage writes per mint/transfer for a view nothing on-chain called | +| New `assert_owner_and_playable(token_id, expected_owner)` | Merges the per-action ownership + playability pair into **one** external call | +| `mint` / `mint_batch_recipients` keep the full token's exact ABI (unsupported params rejected loudly) | Existing call sites — dungeons, budokan, the `minigame::mint` helper — work unchanged against a lite deployment | +| The 251-bit packed token-id layout kept bit-identical (zeros in dead fields) | ~10 call sites and the indexer decode `settings_id`/`minted_by`/lifecycle from the id; 75 freed bits kept as a compatible reserve | +| `minigame::lite::{pre_action, post_action}` helpers | Game code keeps its familiar call-site shape; the module path carries the semantic shift | +| Registry-less `assert_game_registered`: mutual game↔token pairing | Replaces the registry's integrity role — the token→game binding is the one half of the pairing an impostor cannot forge | +| Game-side settings/objectives extensions probe SRC5 before announcing to the token | The unconditional `create_settings` dispatch would have bricked settings creation (including SDM's GameToken constructor) against a lite token | +| `MinigameTokenLite` preset (Ownable + Upgradeable) with two-phase `bind_game` | Production deployable; two-phase init breaks the token↔game mutual-constructor circularity on real networks | + +**Measured (component benches):** per-action guard pair 449k → 271k (−40%); post-action sync 1,648k → 186k against mocks — against the real contract the sync path (6.73M) is deleted outright; warm mint −22%. + +**Final decision — self-binding only.** The lite component originally supported two deployment shapes: embedded in the game contract (one-address) and as a separate token contract paired to a game. Once Phase 4's measurements showed the separate shape strictly worse on gas, supporting it only kept dead machinery alive, so the component was committed to self-binding: the game contract IS the token. The `game_address` storage slot, `bind_game`, the two-phase `register_interfaces` init, the `MinigameTokenLite` preset, and the `minigame::lite::{pre_action, post_action}` cross-contract helpers were all deleted (the game calls the component internally); `game_address()` now returns the contract's own address, and the ecosystem pairing check in `assert_game_registered` simplified to an address equality (`token_address == game_address`), saving a cross-contract call at tournament creation. The `IMinigameTokenLite` ABI and `IMINIGAME_TOKEN_LITE_ID` are unchanged — `mint`'s `game_address` parameter survives for ABI parity (it must equal the contract's own address, same error string as before). + +## Phase 2 — SDM integration (`super-death-mountain` #149) + +| Change | For | +|---|---| +| All 10 GameCore entrypoints: `assert_token_ownership` + `pre_action` → one `lite::pre_action` call | Two token round-trips per action become one | +| `start_game` stops calling `update_game`; emits the ERC-4906 refresh like every other action | There is nothing to latch at start (score 0) — the call was paying the full 6.73M subtree for a no-op | +| Behavioural tests updated; mock gains the merged guard | 869/869 green | + +**Measured (harness, origin/main → #149):** start_game 39.31M → 37.74M; attack 42.69M → 40.61M; 13-action game 80.64M → 77.69M. + +## Phase 3 — budokan v2 (`budokan` #313) + +Decision: with the registry retired, budokan goes **lite-only from day one** (a fresh v2 deployment) rather than carrying dual-mode branches; the existing budokan serves legacy tournaments until they wind down. A dual-mode bridge (#312) was built, measured, and deliberately closed as superseded. + +| Change | For | +|---|---| +| Constructor is `(owner)`; `MetagameComponent` removed | There is no shared "default token" — every mint already resolved `game.token_address()` per tournament | +| Game validity = SRC5 lite-id + mutual game↔token pairing + settings exist | The registry's integrity role, one slot instead of a contract | +| `GameConfig` → `{game_address, settings_id, soulbound}`; `metadata_value` removed from entrypoints; no context/client_url mint decoration | Those surfaces no longer exist on the token; the decoration was `token_uri`-only — budokan's own registration map is the real token→tournament association | +| Game-creator fee shares rejected at creation | The registry that resolved the recipient is gone; a game-declared `IMinigameCreator` surface is the designed replacement (follow-up) | +| Viewer bug fixed: `owner_of` was called on budokan itself | Pre-existing; both affected views reverted for *any* token | + +**Unchanged by design:** scoring (read from the game), leaderboards, prizes, entry requirements, registration — none ever touched the removed surfaces. Tests: 227/32/13 across packages, the whole suite running on the real lite pair. + +## Phase 4 — one address (`super-death-mountain` #150) + +The library-class pattern (already used by budokan's rewards class) dissolved the class-size argument for keeping the token separate: + +| Change | For | +|---|---| +| GameCore **is** the token: ERC721 + lite core + minter + settings + `IMinigameTokenData`, self-bound at construction | The remaining per-action token/settings calls become internal (zero syscalls); the token cannot desync from the game; budokan's pairing check passes as self==self with zero budokan changes | +| Five subsystems become **library classes** behind owner-settable class hashes | A library call executes in GameCore's context — the baked `GAME_CORE_ADDRESS` constant, the subsystem caller-gates, and the two-pass declare/upgrade deployment all disappear; upgrades become one storage write | +| GameToken contract deleted; its settings field-pointer optimizations preserved inside GameCore | One address, one storage | +| Class-size splits: `GameSession` (state load/write, start_game) and `SettingsSystem` (whole-struct settings I/O) as additional library classes sharing GameCore's storage | The naive merge was 130k CASM felts vs the 81,920 limit | + +**First deployment measured a regression** — attack +160k vs the multi-contract stack — because each action crossed *three* library boundaries (load → subsystem → write), serializing the full adventurer+bag four extra times. Since `start_game` runs once but actions run ~75 times, break-even was at two actions: a net loss for real games. + +**The fix (boundary collapse):** `GameSession` now owns the entire action body — load, settings reads, `uses_vrf`, the seal write (strictly before dispatch, preserving the security invariant), the single subsystem library call, write-back, and events — all in the shared storage context. GameCore's entrypoints reduced to: internal guard → **one** session library call → internal refresh. 864/864 tests, zero test changes needed. Final CASM: GameCore 61,877, GameSession 70,263 (limit 81,920). + +## Phase 5 — lite-native token-id layout (`game-components`, this branch) + +Phase 1 kept the full token's 251-bit id layout bit-identical (zeros in the dead fields) to avoid touching call sites during the migration. With no lite deployment on mainnet yet, that compatibility shim was retired before first release: the lite token now has its **own** layout in `token_lite/packing.cairo` (`pack_lite_token_id`, `unpack_lite_token_id`, per-field helpers — same DivRem-chain style as `token::structs`, which stays untouched and keeps serving legacy denshokan). The one truly dead field (`game_id` — self-bound, always this contract) is gone from the id; `settings_id` and `salt` widen to 16 bits each; `metadata` widens from the full token's 16 bits to a 65-bit field absorbing every remaining spare bit. + +Low u128 (128 bits): `minted_at(35) | start_delay(25) | end_delay(25) | settings_id(16) | minted_by(26) | soulbound(1)` + +High u128 (123 bits): `tx_hash(10) | salt(16) | paymaster(1) | has_context(1) | objective_id(30) | metadata(65)` + +| Bits (low) | Field | Bits (high) | Field | +|---|---|---|---| +| 0–34 | minted_at (unix s) | 0–9 | tx_hash (last 10 bits) | +| 35–59 | start_delay | 10–25 | salt (16-bit multicall counter) | +| 60–84 | end_delay (0 = immortal) | 26 | paymaster | +| 85–100 | settings_id (≤ 0xFFFF, ABI stays `Option`) | 27 | has_context (context data NOT stored) | +| 101–126 | minted_by (26-bit minter id) | 28–57 | objective_id (inert, game-interpreted) | +| 127 | soulbound | 58–122 | metadata (65-bit, u128 param ≤ 2^65−1) | + +The high half is **fully allocated — there is no reserved region**: the spare bits were merged into the single writable `metadata` field, in line with the original layout's single-field design. A future protocol-owned field would require a new contract generation — an accepted trade-off. The batch salt bound rises to `salt + sum(counts) - 1 <= 0xFFFF`, and `settings_id > 0xFFFF`, `objective_id > 2^30−1` and `metadata > 2^65−1` are rejected at mint. **The indexer must branch its token-id decode by contract generation:** ids from legacy denshokan decode with the full layout, ids from lite (one-address) contracts with this one. + +### The ABI strip (same branch, deliberate break) + +With the layout compat shim retired, the ABI compat shim went with it — before first release, on the principle: **delete dead machinery and compat shims; keep capability (writes) and cheap client-facing read views.** The `IMinigameTokenLite` trait is now its own surface, not an `IMinigameToken` mirror: + +| Change | Detail | +|---|---| +| `mint` reshaped to 12 args | `mint(player_name, settings_id, start, end, objective_id, context, client_url, to, soulbound, paymaster, salt, metadata)` — no `game_address` (self-bound) and no `renderer_address`/`skills_address` (no per-token renderer/skills surface); `mint_batch_recipients` matches (recipients array in place of `to`) | +| Restored params keep their original full-token behaviors | `objective_id` → packed field, INERT data the game interprets (no completion machinery — `completed_objective` stays always-false); `context` → sets the id's has_context bit only, the data is NOT stored (full-token parity: its context hook was a documented no-op); `client_url` → storage-backed with a `client_url` view (empty default); `paymaster` → packed bit; `metadata` → u128 param packed into the 65-bit field, read via the `mint_metadata` view — the shared `TokenMetadata.metadata: u16` (deployed full-token ABI) cannot hold it and stays 0, never truncated | +| Compat views deleted | `game_address` (was: returns self) and `game_registry_address` (was: returns zero) — consumers now SRC5-probe `IMINIGAME_TOKEN_LITE_ID` instead of resolving addresses; `metagame::assert_game_registered` probes the lite id first and falls through to the unchanged full-token registry path | +| Guards moved internal | `assert_is_playable` and `assert_owner_and_playable` left the ABI — they are the embedding game's own pre-action checks (`InternalTrait`, zero syscalls). Clients read `is_playable` | +| `refresh_metadata_batch` deleted | A multicall of singles | +| Read views + rename kept | `token_metadata`, `is_playable`, `settings_id`, `minted_by`, `minted_by_address`, `is_soulbound`, `objective_id`, `client_url`, `mint_metadata`, `player_name` (near-zero-cost client/RPC conveniences) and `update_player_name` (owner-gated capability) stay | +| Legacy id registration dropped | `initializer()` registers ONLY `IMINIGAME_TOKEN_LITE_ID` — SRC5 is honest; `supports_interface(IMINIGAME_TOKEN_ID)` is now false on lite tokens | +| New interface id | `IMINIGAME_TOKEN_LITE_ID = 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea` (rederived over the 14-function surface minus `refresh_metadata`, per the refresh-exclusion convention) | + +**Downstream:** SDM dungeons/GameCore and budokan v2 migrate their mint call sites to the 12-arg shape (drop the game-address, renderer and skills arguments; the remaining params keep their full-token positions and meanings — pass `None`/`false`/`0` where unused, noting `metadata` is now `u128`), GameCore's guard becomes the component-internal call, and anything that asserted the full-token id or called `game_registry_address()` on a lite token switches to the lite-id SRC5 probe. + +--- + +## Final measured results + +**Sepolia, real transactions, identical action (first attack on the starter beast), total tx L2 gas:** + +| | mint | start_game | attack (×N per game) | +|---|---|---|---| +| Multi-contract lite (#149) | 4,602,400 | 5,315,280 | 4,680,320 | +| One-address, 3 boundaries | 4,602,400 | 4,995,280 | 4,840,320 ❌ | +| **One-address, 2 boundaries (final)** | 4,602,400 | **4,995,280 (−6.0%)** | **4,440,320 (−5.1%)** | + +**Per-action estimates for the other entrypoints.** Method: the harness's cumulative benches yield a per-action execution delta, and adding each stack's measured protocol overhead (on-chain attack minus harness attack: 1,805,666 for #149, 1,834,426 for the final stack) reproduces the measured attack numbers exactly — so the same anchor gives reliable estimates for the actions we benched but didn't send on-chain: + +| Action (total tx L2 gas) | #149 stack | One-address final | Δ | +|---|---|---|---| +| start_game *(measured)* | 5,315,280 | 4,995,280 | −320k (−6.0%) | +| attack *(measured)* | 4,680,320 | 4,440,320 | −240k (−5.1%) | +| explore | ~4,342,000 | ~4,094,000 | ~−250k (−5.7%) | +| surrender | ~3,113,000 | ~3,019,000 | ~−95k (−3.0%) | +| select_stat_upgrades | ~3,787,000 | ~3,868,000 | ≈ break-even¹ | +| flee / equip / drop_items / buy_items | *not benchmarked* | | structurally the same call pattern; expect flee ≈ attack-class, equip/drop ≈ stat-upgrades-class, buy_items between | + +¹ The architectural saving is a roughly constant *absolute* amount per action (removed token/settings calls vs. the session boundary's adventurer+bag payload). Heavy actions (attack, explore) net −5–6%; the lightest action (stat upgrades) is where the fixed boundary payload roughly cancels the removed calls. Two effects bias this table *against* the final stack: the #149 harness numbers use a mock token that deliberately under-charges the real token calls by ~60–100k/action (true #149 costs are higher, so true deltas are better across the board, pulling stat upgrades to neutral-or-better), and light actions are exactly the ones that gain most from client-side batching, since their cost is dominated by the ~1.8M protocol overhead. + +**Harness, full architecture span (origin/main → final):** attack −15.3%, start_game −14.7%, 13-action game −9.7% — understated, because the harness mock charged a fraction of real denshokan costs and the original also paid the ~6.73M keeper latch per game that no longer exists. + +**Per beast-mode game (75 actions):** ~388M gas → ~343M ≈ **$0.40 → ~$0.35 (−12%)**. At 75 actions the per-action cost is 94% of the game, and ~2M of every action transaction is protocol overhead (validation, fee transfer, calldata) that no contract architecture can remove — ~44% of the game's total. **Next lever: client-side action batching** (multicall across actions, trivial now that everything is one address): ~$0.28 at 2 actions/tx, ~$0.25 at 3. + +**Live E2E proof (Sepolia):** the full loop — budokan v2 `create_tournament` → `enter_tournament` (entry token minted by budokan on the game contract itself) → `start_game` → `attack` → `submit_score` → leaderboard — ran on-chain against both the multi-contract stack (tournament 1) and the one-address stack (tournament 2, after upgrading the live GameCore *in place* with two invokes). Key addresses: one-address GameCore `0x04f9bf2c7ace4ca777048e5fa4c0aa0206cc6c3fe856a244f65c11ff2da16bd4`, budokan v2 `0x0759dce1485904dc9a3db04480f06b07d4d34b08a2315bbbf7769625df15ec7a`, standalone lite token `0x061a97eb76ee193e61bb1777b66dfec365b2d50a7281ab8310024278b479032a`. + +--- + +## Rollout order and open items + +1. **Merge #123** (game-components) and cut a release tag. +2. **#149 → #150** (SDM) and **#313** (budokan): flip each `TEMPORARY PIN` from branch `feat/token-lite` to the tag, refresh locks. +3. Deploy per network. Note: Sepolia (Starknet 0.14.3) accepts Sierra ≤1.7 — SDM's 2.20 toolchain output is rejected there; #150 currently pins `starknet = 2.16.1` (reviewer decision needed on repo toolchain). + +**Open follow-ups:** client-side action batching (the biggest remaining per-game lever); `IMinigameCreator` game-declared fee surface; negative-path tests for the lite pairing check; indexer/SDK migration for budokan v2's slimmed ABI and the one-address event source; a production `token_uri` (the renderer contract is wired but `create_metadata` has a pre-existing u64 truncation for packed ids); rewrite of SDM's `scripts/deploy*.sh` for the 3-step flow; decision on where game metadata (name/image/genre) lives now that the registry is gone. + +--- + +## The lite token is the standard + +As the final step of this migration, the repo renamed the lite token to be THE +minigame token standard and demoted the original full token to an explicit +"legacy" naming. This is a **source-level rename only**: every interface-id +VALUE and selector is unchanged — deployed contracts registered them on-chain +and they are frozen forever. Downstream repos should migrate names using this +map: + +| Old name (pre-rename) | New standard name | Legacy name | +| --- | --- | --- | +| `IMinigameTokenLite` (+`Dispatcher`/`DispatcherTrait`) | `IMinigameToken` (+`Dispatcher`/`DispatcherTrait`) | — | +| `IMINIGAME_TOKEN_LITE_ID` (= `0x15951d…7aea`) | `IMINIGAME_TOKEN_ID` (same value) | — | +| `IMinigameToken` (original full trait) | — | `IMinigameTokenLegacy` | +| `IMINIGAME_TOKEN_ID` (original, = `0x246f61…9906`) | — | `IMINIGAME_TOKEN_LEGACY_ID` (same value) | +| `game_components_interfaces::token::lite` | `game_components_interfaces::token::core` | — | +| `game_components_interfaces::token::core` (original) | — | `game_components_interfaces::token::legacy` | +| `embeddable_game_standard::token_lite` module | `embeddable_game_standard::token` | — | +| `embeddable_game_standard::token` module (original) | — | `embeddable_game_standard::token_legacy` (all names inside unchanged: `CoreTokenComponent`, `structs::PackedTokenId`, …) | +| `CoreTokenLiteComponent` (+`CoreTokenLiteImpl`) | `MinigameTokenComponent` (+`MinigameTokenImpl`) | — | +| `token_lite::packing::{LitePackedTokenId, pack_lite_token_id, unpack_lite_token_id}` | `token::packing::{PackedTokenId, pack_token_id, unpack_token_id}` | — | +| Error prefixes `"MinigameTokenLite: …"` / `"LitePackedTokenId: …"` | `"MinigameToken: …"` / `"PackedTokenId: …"` | — | +| `LiteGameMock` (`test_common::mocks::lite_game_mock`) | `StandardGameMock` (`test_common::mocks::standard_game_mock`) | — | + +Additionally, **the minter is standard, not optional**: the legacy +`MinterComponent`'s substance (storage under the same variable names, +`IMinigameTokenMinter` impl, `MinterRegistryUpdate` event, +`IMINIGAME_TOKEN_MINTER_ID` registration) is absorbed directly into +`MinigameTokenComponent`. Embedders drop the separate +`component!(MinterComponent…)` wiring and embed +`MinigameTokenComponent::MinterImpl` instead; the `OptionalMinter` indirection +remains only in `token_legacy`. `IMinigameTokenMinter` and its id are +unchanged, and the standard token's SRC5 id was NOT rederived (its trait did +not change). diff --git a/packages/economy/src/tokenomics/AGENTS.md b/packages/economy/src/tokenomics/AGENTS.md index 31f0f1ab..7e5caece 100644 --- a/packages/economy/src/tokenomics/AGENTS.md +++ b/packages/economy/src/tokenomics/AGENTS.md @@ -33,6 +33,7 @@ Permissionless buyback execution using Ekubo TWAMM DCA orders. |----------|-------------| | `set_global_config(config)` | Update global defaults | | `set_token_config(sell_token, config)` | Set/clear per-token config | +| `set_require_token_config(required)` | Strict mode: when true, tokens without an explicit per-token config revert (`'No config for token'`) instead of falling back to global defaults. Default false (historic behavior). Sweep is unaffected | ### Structs diff --git a/packages/economy/src/tokenomics/buyback/buyback.cairo b/packages/economy/src/tokenomics/buyback/buyback.cairo index 56a75681..ef3b8ad1 100644 --- a/packages/economy/src/tokenomics/buyback/buyback.cairo +++ b/packages/economy/src/tokenomics/buyback/buyback.cairo @@ -43,6 +43,12 @@ pub mod BuybackComponent { Buyback_extension_address: ContractAddress, /// Per-token configuration overrides (None = use global defaults) Buyback_token_config: Map>, + /// Strict mode: when true, tokens without an explicit per-token config + /// revert instead of falling back to the global defaults (Ekubo + /// revenue_buybacks' Option::None default-config semantics, adapted — + /// our global config stays mandatory because sweep reads + /// buy_token/treasury from it; only the trading POLICY is gated) + Buyback_require_token_config: bool, /// Position token ID per sell token (0 if not created) Buyback_position_token_id: Map, /// Number of orders created per sell token @@ -67,6 +73,7 @@ pub mod BuybackComponent { BuyTokenSwept: BuyTokenSwept, GlobalConfigUpdated: GlobalConfigUpdated, TokenConfigUpdated: TokenConfigUpdated, + RequireTokenConfigUpdated: RequireTokenConfigUpdated, } /// Emitted when a new buyback order is started @@ -121,6 +128,12 @@ pub mod BuybackComponent { pub new_config: Option, } + /// Emitted when strict per-token config mode is toggled + #[derive(Drop, starknet::Event)] + pub struct RequireTokenConfigUpdated { + pub required: bool, + } + /// External implementation of IBuyback /// Uses `#[embeddable_as]` to allow embedding in contracts #[embeddable_as(BuybackImpl)] @@ -541,6 +554,8 @@ pub mod BuybackComponent { match self.Buyback_token_config.read(sell_token) { Option::Some(config) => config, Option::None => { + // Strict mode: only explicitly-configured tokens may trade + assert(!self.Buyback_require_token_config.read(), Errors::NO_CONFIG_FOR_TOKEN); // Build default config from global settings let global = self.Buyback_global_config.read(); TokenBuybackConfig { @@ -608,5 +623,16 @@ pub mod BuybackComponent { self.Buyback_token_config.write(sell_token, config); self.emit(TokenConfigUpdated { sell_token, old_config, new_config: config }); } + + /// Toggle strict per-token config mode (internal - should be protected + /// by embedding contract). When enabled, `_get_effective_config` + /// reverts for tokens without an explicit config instead of falling + /// back to the global defaults. `sweep_buy_token_to_treasury` is + /// unaffected — it reads identity (buy_token/treasury) off the global + /// config, which stays mandatory. + fn set_require_token_config(ref self: ComponentState, required: bool) { + self.Buyback_require_token_config.write(required); + self.emit(RequireTokenConfigUpdated { required }); + } } } diff --git a/packages/economy/src/tokenomics/constants.cairo b/packages/economy/src/tokenomics/constants.cairo index 8974abdf..2b346a85 100644 --- a/packages/economy/src/tokenomics/constants.cairo +++ b/packages/economy/src/tokenomics/constants.cairo @@ -33,6 +33,7 @@ pub mod Errors { pub const NO_BUY_TOKEN_TO_SWEEP: felt252 = 'No buy token to sweep'; // Config consistency errors + pub const NO_CONFIG_FOR_TOKEN: felt252 = 'No config for token'; pub const BUY_TOKEN_MISMATCH: felt252 = 'Buy token mismatch'; pub const FEE_MISMATCH: felt252 = 'Fee mismatch'; pub const MIN_DELAY_GT_MAX_DELAY: felt252 = 'min_delay > max_delay'; diff --git a/packages/economy/src/tokenomics/tests/mocks/test_autonomous_buyback.cairo b/packages/economy/src/tokenomics/tests/mocks/test_autonomous_buyback.cairo index 53ff218a..bd256daa 100644 --- a/packages/economy/src/tokenomics/tests/mocks/test_autonomous_buyback.cairo +++ b/packages/economy/src/tokenomics/tests/mocks/test_autonomous_buyback.cairo @@ -57,6 +57,11 @@ pub mod AutonomousBuyback { self.buyback.set_global_config(config); } + fn set_require_token_config(ref self: ContractState, required: bool) { + self.ownable.assert_only_owner(); + self.buyback.set_require_token_config(required); + } + fn set_token_config( ref self: ContractState, sell_token: ContractAddress, diff --git a/packages/economy/src/tokenomics/tests/test_buyback.cairo b/packages/economy/src/tokenomics/tests/test_buyback.cairo index 1c13bb5a..176f176f 100644 --- a/packages/economy/src/tokenomics/tests/test_buyback.cairo +++ b/packages/economy/src/tokenomics/tests/test_buyback.cairo @@ -1654,3 +1654,64 @@ fn test_set_token_config_rejects_min_duration_gt_max_duration() { admin_dispatcher.set_token_config(sell_token, Option::Some(invalid_config)); stop_cheat_caller_address(contract); } + +// ============================================================================ +// Strict Per-Token Config Mode (require_token_config) +// ============================================================================ + +#[test] +#[should_panic(expected: 'No config for token')] +fn test_require_token_config_blocks_unconfigured_token() { + let buyback_token = deploy_mock_erc20("Buyback", "BUY"); + let sell_token = deploy_mock_erc20("Sell", "SELL"); + let contract = setup_buyback_contract(buyback_token); + let dispatcher = IBuybackDispatcher { contract_address: contract }; + let admin_dispatcher = IBuybackAdminDispatcher { contract_address: contract }; + + start_cheat_caller_address(contract, OWNER()); + admin_dispatcher.set_require_token_config(true); + stop_cheat_caller_address(contract); + + // No per-token config exists: strict mode must refuse the global fallback + dispatcher.get_effective_config(sell_token); +} + +#[test] +fn test_require_token_config_allows_configured_token_and_toggles_off() { + let buyback_token = deploy_mock_erc20("Buyback", "BUY"); + let sell_token = deploy_mock_erc20("Sell", "SELL"); + let unconfigured = deploy_mock_erc20("Other", "OTH"); + let contract = setup_buyback_contract(buyback_token); + let dispatcher = IBuybackDispatcher { contract_address: contract }; + let admin_dispatcher = IBuybackAdminDispatcher { contract_address: contract }; + + start_cheat_caller_address(contract, OWNER()); + admin_dispatcher.set_require_token_config(true); + admin_dispatcher + .set_token_config( + sell_token, Option::Some(defaults::token_config_with(buyback_token, TREASURY())), + ); + stop_cheat_caller_address(contract); + + // Explicitly-configured token trades under strict mode + let config = dispatcher.get_effective_config(sell_token); + assert(config.buy_token == buyback_token, 'Wrong buy token'); + + // Toggling strict mode off restores the historic global fallback + start_cheat_caller_address(contract, OWNER()); + admin_dispatcher.set_require_token_config(false); + stop_cheat_caller_address(contract); + let fallback = dispatcher.get_effective_config(unconfigured); + assert(fallback.buy_token == buyback_token, 'Fallback should work again'); +} + +#[test] +#[should_panic(expected: 'Caller is not the owner')] +fn test_non_owner_cannot_set_require_token_config() { + let buyback_token = deploy_mock_erc20("Buyback", "BUY"); + let contract = setup_buyback_contract(buyback_token); + let admin_dispatcher = IBuybackAdminDispatcher { contract_address: contract }; + + start_cheat_caller_address(contract, USER1()); + admin_dispatcher.set_require_token_config(true); +} diff --git a/packages/embeddable_game_standard/Scarb.toml b/packages/embeddable_game_standard/Scarb.toml index c3f253ff..4e697588 100644 --- a/packages/embeddable_game_standard/Scarb.toml +++ b/packages/embeddable_game_standard/Scarb.toml @@ -28,4 +28,5 @@ build-external-contracts = [ "game_components_test_common::mocks::minigame_mock::minigame_mock", "game_components_test_common::mocks::metagame_mock::metagame_mock", "game_components_test_common::mocks::mock_game::MockGame", + "game_components_test_common::mocks::standard_game_mock::StandardGameMock", ] diff --git a/packages/embeddable_game_standard/src/lib.cairo b/packages/embeddable_game_standard/src/lib.cairo index 5a351bf1..382284f4 100644 --- a/packages/embeddable_game_standard/src/lib.cairo +++ b/packages/embeddable_game_standard/src/lib.cairo @@ -2,3 +2,4 @@ pub mod metagame; pub mod minigame; pub mod registry; pub mod token; +pub mod token_legacy; diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index 4b670401..ca3beede 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -6,31 +6,54 @@ High-level game management component for token delegation and minting coordinati ```cairo #[storage] -pub struct Storage { - context_address: ContractAddress, // Optional IMetagameContext - default_token_address: ContractAddress, // Required MinigameToken -} +pub struct Storage {} // Self-bound: no addresses to hold ``` +The component is **self-binding**, like `MinigameTokenComponent`: the embedding +contract IS the metagame. It stores no addresses. + +- **No default token** — every game brings its own, resolved from + `game_address` on each mint. +- **No context address** — a metagame that provides context embeds + `ContextComponent` itself, which registers `IMETAGAME_CONTEXT_ID` on this + same contract. Nothing ever resolved a provider through a stored address: + the legacy token takes context as a mint parameter. + ## Interfaces -### IMetagame (Read-only) +### IMetagame — REMOVED -| Method | Returns | Description | -|--------|---------|-------------| -| `context_address()` | `ContractAddress` | Optional context contract (tournaments/events) | -| `default_token_address()` | `ContractAddress` | Default MinigameToken for minting | +With no addresses to expose, the trait had no methods left, so `IMetagame` and +`IMETAGAME_ID` are gone (an SRC5 id cannot be derived from an empty selector +set, and nothing probed the old +`0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2`). -**Interface ID**: `0x0260d5160a283a03815f6c3799926c7bdbec5f22e759f992fb8faf172243ab20` +Discover a metagame through the surfaces that still carry meaning: +`IMETAGAME_CONTEXT_ID` for a context provider, `IMETAGAME_CALLBACK_ID` for a +legacy callback receiver. The component now exposes internals only. ### InternalTrait (Component internals) | Method | Description | |--------|-------------| -| `initializer(context_address, default_token_address)` | Initialize with optional context | -| `mint(game_address, player_name, settings_id, ...)` | Mint single token | -| `mint_batch(mints: Array)` | Batch mint tokens | +| `mint(game_address, player_name, settings_id, ...)` | Mint a single token | +| `mint_batch(mints: Array)` | Many tokens, **one call per token**; each entry may name a different game | +| `mint_batch_recipients(game_address, ..., recipients, ..., metadata: u128)` | Many tokens for **ONE** game in a **single dispatch**, via the token's own batch entrypoint | | `assert_game_registered(game_address)` | Validate game registration | +| `get_game_fee_info(game_address)` / `get_game_fee_recipient(...)` / `pay_game_fee(...)` | Resolve a game's fee terms and recipient, and pay them | + +**Choosing between the batch calls:** if the batch shares a game — a tournament +entry, say — use `mint_batch_recipients`. `mint_batch` costs one cross-contract +dispatch per token and re-serialises `context` (which contains an `Array`) each +time; `mint_batch_recipients` hoists the batch-invariant work and runs a single +global salt counter. Reach for `mint_batch` only when entries genuinely name +different games. + +Every mint path takes `metadata: u128`, reaching the standard token's 65-bit +field. The legacy token's field is `u16`, so a legacy mint asserts the value +fits (`Metagame: metadata exceeds u16`) rather than truncating it silently — +identically on the single and batch paths, so the two never disagree about what +a legacy token accepts. ## Extensions @@ -74,30 +97,46 @@ mod MyMetagame { src5: SRC5Component::Storage, } - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; + impl MetagameInternalImpl = MetagameComponent::InternalImpl; } ``` +There is no `#[abi(embed_v0)]` line and no `initializer` call: the component +exposes no ABI (see "IMetagame — REMOVED") and holds no state. A metagame that +also provides context additionally embeds `ContextComponent` and calls +`self.context.initializer()`. + ## Relationships ``` -Metagame - |-- default_token_address --> MinigameToken (REQUIRED) - |-- context_address --------> IMetagameContext (OPTIONAL) +Metagame (self-bound: this contract) + |-- embeds ContextComponent ---> IMETAGAME_CONTEXT_ID on this address (OPTIONAL) + `-- per-mint: game_address ----> IMinigame.token_address() --> the game's token ``` +Both token generations are served, branched on SRC5: a token supporting +`IMINIGAME_TOKEN_ID` is a self-bound standard token, otherwise it is treated as +a legacy registry-backed token. This applies to `assert_game_registered`, +`mint`/`mint_batch` and `get_game_fee_info`/`pay_game_fee`. + ## Initialization Requirements -- `default_token_address` MUST support `IMINIGAME_TOKEN_ID` -- `context_address` (if provided) MUST support `IMETAGAME_CONTEXT_ID` -- Both addresses validated via SRC5 introspection on init +None — the component has no `initializer`. A metagame that provides context +calls `ContextComponent::initializer()` on itself; a legacy callback receiver +calls `MetagameCallbackComponent::initializer(token_address)`. + +## Callback extension + +`MetagameCallbackComponent::initializer(token_address)` binds the LEGACY token +allowed to call back. Callbacks fire from `update_game()`, which the standard +self-bound token does not have — so the callback extension is legacy-only and +owns its token binding rather than reading one off `MetagameComponent`. ## MintMetagameParams ```cairo pub struct MintMetagameParams { - pub game_address: Option, + pub game_address: ContractAddress, pub player_name: Option, pub settings_id: Option, pub start: Option, @@ -106,7 +145,14 @@ pub struct MintMetagameParams { pub context: Option, pub client_url: Option, pub renderer_address: Option, + pub skills_address: Option, pub to: ContractAddress, pub soulbound: bool, + pub paymaster: bool, + pub salt: u16, + pub metadata: u128, } ``` + +`renderer_address` and `skills_address` exist for legacy tokens only; a +standard-token mint rejects them loudly rather than dropping them silently. diff --git a/packages/embeddable_game_standard/src/metagame/README.md b/packages/embeddable_game_standard/src/metagame/README.md index 63cffc9a..e8407c73 100644 --- a/packages/embeddable_game_standard/src/metagame/README.md +++ b/packages/embeddable_game_standard/src/metagame/README.md @@ -12,20 +12,17 @@ High-level game management component for token delegation and minting coordinati ## Interface -### IMetagame (Read-only) +### IMetagame — REMOVED -| Method | Returns | Description | -|--------|---------|-------------| -| `context_address()` | `ContractAddress` | Optional context contract (tournaments/events) | -| `default_token_address()` | `ContractAddress` | Default MinigameToken for minting | - -**Interface ID**: `0x0260d5160a283a03815f6c3799926c7bdbec5f22e759f992fb8faf172243ab20` +The component is self-bound and stores no addresses, so the trait had no +methods left; `IMetagame` and `IMETAGAME_ID` are gone. Discover a metagame via +`IMETAGAME_CONTEXT_ID` (context provider) or `IMETAGAME_CALLBACK_ID` (legacy +callback receiver). ### InternalTrait | Method | Description | |--------|-------------| -| `initializer(context_address, default_token_address)` | Initialize with optional context | | `mint(game_address, player_name, settings_id, ...)` | Mint single token | | `mint_batch(mints: Array)` | Batch mint tokens | | `assert_game_registered(game_address)` | Validate game registration | @@ -108,7 +105,7 @@ Metagame ## Initialization Requirements -- `default_token_address` MUST support `IMINIGAME_TOKEN_ID` +- `default_token_address` MUST support `IMINIGAME_TOKEN_LEGACY_ID` (the legacy multi-game token) - `context_address` (if provided) MUST support `IMETAGAME_CONTEXT_ID` - Both addresses validated via SRC5 introspection on init diff --git a/packages/embeddable_game_standard/src/metagame/extensions/callback/callback.cairo b/packages/embeddable_game_standard/src/metagame/extensions/callback/callback.cairo index 4f5dc81d..ef1b8ea9 100644 --- a/packages/embeddable_game_standard/src/metagame/extensions/callback/callback.cairo +++ b/packages/embeddable_game_standard/src/metagame/extensions/callback/callback.cairo @@ -6,24 +6,32 @@ // // Uses the hooks pattern: the component provides infrastructure and SRC5 // registration, while implementations define actual callback behavior via traits. +// +// Callbacks are a LEGACY-token concept: they fire from `update_game()`, which +// the standard self-bound token does not have. The component therefore stores +// its own legacy token address rather than reading one off MetagameComponent, +// which no longer carries a metagame-wide default token. #[starknet::component] pub mod MetagameCallbackComponent { + use core::num::traits::Zero; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; - use starknet::get_caller_address; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + use starknet::{ContractAddress, get_caller_address}; use crate::metagame::extensions::callback::interface::{ IMETAGAME_CALLBACK_ID, IMetagameCallback, }; - use crate::metagame::metagame_component::MetagameComponent; - use crate::metagame::metagame_component::MetagameComponent::MetagameImpl; // ========================================================================== // STORAGE // ========================================================================== #[storage] - pub struct Storage {} + pub struct Storage { + /// The legacy token contract allowed to invoke these callbacks. + token_address: ContractAddress, + } // ========================================================================== // HOOKS TRAIT @@ -59,7 +67,6 @@ pub mod MetagameCallbackComponent { +HasComponent, +Drop, +MetagameCallbackHooksTrait, - impl Metagame: MetagameComponent::HasComponent, impl SRC5: SRC5Component::HasComponent, > of IMetagameCallback> { fn on_game_action(ref self: ComponentState, token_id: u256, score: u64) { @@ -92,22 +99,27 @@ pub mod MetagameCallbackComponent { TContractState, +HasComponent, impl SRC5: SRC5Component::HasComponent, - impl Metagame: MetagameComponent::HasComponent, +Drop, > of InternalTrait { - /// Initializes the component by registering the SRC5 interface. + /// Initializes the component by registering the SRC5 interface and + /// binding the legacy token allowed to call back. /// Should be called in the contract's constructor. - fn initializer(ref self: ComponentState) { + fn initializer(ref self: ComponentState, token_address: ContractAddress) { + assert!(!token_address.is_zero(), "MetagameCallback: token address is zero"); + self.token_address.write(token_address); let mut src5_component = get_dep_component_mut!(ref self, SRC5); src5_component.register_interface(IMETAGAME_CALLBACK_ID); } - /// Asserts that the caller is the token contract registered in MetagameComponent. + /// The legacy token contract bound at initialization. + fn token_address(self: @ComponentState) -> ContractAddress { + self.token_address.read() + } + + /// Asserts that the caller is the bound token contract. fn assert_only_token(self: @ComponentState) { - let metagame = get_dep_component!(self, Metagame); - let token_address = metagame.default_token_address(); assert!( - get_caller_address() == token_address, + get_caller_address() == self.token_address.read(), "MetagameCallback: caller is not the token contract", ); } diff --git a/packages/embeddable_game_standard/src/metagame/interface.cairo b/packages/embeddable_game_standard/src/metagame/interface.cairo index 32a8b048..8f4a455a 100644 --- a/packages/embeddable_game_standard/src/metagame/interface.cairo +++ b/packages/embeddable_game_standard/src/metagame/interface.cairo @@ -1,4 +1,2 @@ -// Re-export from interfaces package for backward compatibility -pub use game_components_interfaces::metagame::{ - IMETAGAME_ID, IMetagame, IMetagameDispatcher, IMetagameDispatcherTrait, -}; +// `IMetagame` was removed — a metagame is self-bound and exposes no addresses. +// See `game_components_interfaces::metagame::core` for the rationale. diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index b4465212..76eac17c 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -1,28 +1,60 @@ +use core::num::traits::Zero; use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; use game_components_embeddable_game_standard::minigame::interface::{ IMinigameDispatcher, IMinigameDispatcherTrait, }; use game_components_embeddable_game_standard::registry::interface::{ FEE_DENOMINATOR, GameFeeInfo, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, + default_license, }; -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; +use game_components_interfaces::structs::token::MintBatchRecipient; +use game_components_interfaces::token::core::{ + IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +}; +use game_components_interfaces::token::game_fee::{ + IMINIGAME_TOKEN_GAME_FEE_ID, IMinigameTokenGameFeeDispatcher, + IMinigameTokenGameFeeDispatcherTrait, +}; +use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::metagame::structs::MintMetagameParams; /// Asserts that a game is registered in the minigame token contract /// +/// The token is probed via SRC5 first: a token supporting +/// `IMINIGAME_TOKEN_ID` is a self-bound standard token (the game contract IS +/// the token — standard tokens expose no registry/game-address views), so +/// "registered" reduces to a plain address equality: the game's +/// `token_address()` must be the game itself. Otherwise the token is a legacy +/// token: registry-backed (multi-game) tokens ask the registry, while a zero +/// `game_registry_address()` marks a single-game legacy token, whose paired +/// game is named by its own `game_address()` view — a legacy token is a +/// SEPARATE contract from its game, so the pairing there is +/// `token.game_address() == game_address`, not an address equality against +/// the token itself. +/// /// # Arguments -/// * `minigame_token_address` - The address of the minigame token contract /// * `game_address` - The address of the game contract to check pub fn assert_game_registered(game_address: ContractAddress) { let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; let minigame_token_address = minigame_dispatcher.token_address(); - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let token_src5_dispatcher = ISRC5Dispatcher { contract_address: minigame_token_address }; + if token_src5_dispatcher.supports_interface(IMINIGAME_TOKEN_ID) { + assert!(minigame_token_address == game_address, "Game is not registered"); + return; + } + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); + if minigame_registry_address.is_zero() { + assert!(minigame_token_dispatcher.game_address() == game_address, "Game is not registered"); + return; + } let minigame_registry_dispatcher = IMinigameRegistryDispatcher { contract_address: minigame_registry_address, }; @@ -30,10 +62,90 @@ pub fn assert_game_registered(game_address: ContractAddress) { assert!(game_exists, "Game is not registered"); } -/// Mints a game token through the minigame token contract +/// The standard token's registration check: it is self-bound, so the game must +/// BE its token. Every path that trusts a game's `token_address()` must apply +/// this — otherwise a hostile contract can name a standard token it does not +/// own and have the metagame mint on it or pay its fee recipient. +fn assert_self_bound(token_address: ContractAddress, game_address: ContractAddress) { + assert!(token_address == game_address, "Game is not registered"); +} + +/// True when `token_address` is a self-bound standard token (SRC5 +/// `IMINIGAME_TOKEN_ID`) rather than a legacy multi-game token. +fn is_standard_token(token_address: ContractAddress) -> bool { + ISRC5Dispatcher { contract_address: token_address }.supports_interface(IMINIGAME_TOKEN_ID) +} + +/// Rejects a game whose token is not itself, on the way into a legacy mint. +/// +/// The mint paths discriminate on `token_address() == game_address` rather +/// than by probing SRC5, because self-boundness IS what makes a token +/// standard: a legacy token is structurally a separate contract from its +/// game. Address equality answers the same question for free, and these are +/// the hot paths — a probe there costs a cross-contract call on every mint, +/// which for a tournament metagame is every entry. +/// +/// The probe still has to happen somewhere, or a game naming a standard token +/// it does not own would fall into the legacy branch and fail on a missing +/// entrypoint instead of saying what is wrong. So it happens here, on the +/// branch that is already not self-bound — legacy mints pay for it, standard +/// mints no longer do, and the panic is unchanged either way. +fn assert_not_a_foreign_standard_token( + token_address: ContractAddress, game_address: ContractAddress, +) { + if is_standard_token(token_address) { + assert_self_bound(token_address, game_address); + } +} + +/// Mints on a self-bound standard token. +/// +/// The standard `mint` has no game address (the token IS the game) and no +/// per-token renderer/skills. Those two parameters are rejected loudly rather +/// than silently dropped — a caller that asked for a custom renderer must not +/// be told the mint honoured it. +fn mint_standard_token( + token_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, +) -> felt252 { + assert!(renderer_address.is_none(), "Metagame: standard tokens have no per-token renderer"); + assert!(skills_address.is_none(), "Metagame: standard tokens have no per-token skills"); + IMinigameTokenDispatcher { contract_address: token_address } + .mint( + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + to, + soulbound, + paymaster, + salt, + metadata, + ) +} + +/// Mints a game token through the game's own token contract. +/// +/// Every game brings its own token — the token is resolved from +/// `game_address` on every mint, so there is no metagame-wide default token. /// /// # Arguments -/// * `minigame_token_address` - The address of the minigame token contract /// * `game_address` - The address of the game contract minting the token /// * `player_name` - Optional player name /// * `settings_id` - Optional settings ID @@ -50,8 +162,7 @@ pub fn assert_game_registered(game_address: ContractAddress) { /// # Returns /// * `u64` - The minted token ID pub fn mint( - default_token_address: ContractAddress, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -65,75 +176,168 @@ pub fn mint( soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { - match game_address { - // If the game address is provided, mint a token through the token contract the game - // supports (could include its own game registry) - Option::Some(game_address) => { - let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; - let minigame_token_address = minigame_dispatcher.token_address(); - let minigame_token_dispatcher = IMinigameTokenDispatcher { - contract_address: minigame_token_address, - }; - minigame_token_dispatcher - .mint( - game_address, - player_name, - settings_id, - start, - end, - objective_id, - context, - client_url, - renderer_address, - skills_address, - to, - soulbound, - paymaster, - salt, - metadata, - ) - }, - // If no game address is provided, mint a token through the default token contract (blank - // game) - Option::None => { - let minigame_token_dispatcher = IMinigameTokenDispatcher { - contract_address: default_token_address, - }; - minigame_token_dispatcher - .mint( - core::num::traits::Zero::zero(), - player_name, - settings_id, - start, - end, - objective_id, - context, - client_url, - renderer_address, - skills_address, - to, - soulbound, - paymaster, - salt, - metadata, - ) - }, + let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; + let minigame_token_address = minigame_dispatcher.token_address(); + // A standard token is self-bound and carries a different mint ABI; + // `assert_game_registered` accepts these games, so this path must be able + // to mint for them too — under the same pairing check, which here IS the + // discriminator rather than a follow-up to one. + // + // Reading self-boundness as "standard" assumes no LEGACY token is ever + // self-bound. That is a description of deployed reality — a legacy token + // is a separate contract from its game — and not an invariant anything + // enforces, so state it rather than leave it implicit. A self-bound legacy + // token would take this branch and fail loudly on the standard mint's + // missing entrypoint, and `assert_game_registered`'s zero-registry branch + // would still admit it (`token.game_address() == game_address` can hold + // when the two are the same contract), so the two disagree about exactly + // that shape. Accepted knowingly: the failure is a revert at first mint, + // never silent, and the alternative is the per-mint probe this exists to + // remove. + if minigame_token_address == game_address { + return mint_standard_token( + minigame_token_address, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + to, + soulbound, + paymaster, + salt, + metadata, + ); } + assert_not_a_foreign_standard_token(minigame_token_address, game_address); + + // Legacy token: narrower metadata field — reject rather than truncate. + let legacy_metadata: u16 = metadata.try_into().expect('Metagame: metadata exceeds u16'); + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { + contract_address: minigame_token_address, + }; + minigame_token_dispatcher + .mint( + game_address, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + to, + soulbound, + paymaster, + salt, + legacy_metadata, + ) } -/// Mints multiple game tokens in batch through minigame token contracts +/// Mints many tokens for ONE game in a single call, via the token's own +/// `mint_batch_recipients` entrypoint. +/// +/// This is NOT `mint_batch`. `mint_batch` loops over `mint`, one cross-contract +/// dispatch per token, and each entry may name a different game. This routes a +/// single dispatch to the token's batch entrypoint, which hoists the +/// batch-invariant work (packing, the shared has_context bit) and runs one +/// global salt counter across the batch. For a many-recipient single-game mint +/// — a tournament entry — that is the difference between N dispatches and one, +/// with the `context` array re-serialised N times versus once. +/// +/// Both token generations are served. `metadata` is `u128` to reach the +/// standard token's 65-bit field; the legacy token's field is `u16`, so a +/// legacy mint asserts the value fits rather than truncating it silently. /// /// # Arguments -/// * `default_token_address` - The default token address for minting when no game_address is -/// provided * `mints` - Array of mint parameters for each token +/// * `game_address` - The game whose token mints; the token is resolved from it +/// * `recipients` - Per-recipient counts; salts run `salt .. salt + sum(counts) - 1` /// /// # Returns -/// * `Array` - Array of minted token IDs -pub fn mint_batch( - default_token_address: ContractAddress, mints: Array, +/// * `Array` - The minted token ids, in recipient order +pub fn mint_batch_recipients( + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, ) -> Array { + let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; + let minigame_token_address = minigame_dispatcher.token_address(); + + // Same discriminator, and the same assumption, as `mint` — see there. + if minigame_token_address == game_address { + assert!(renderer_address.is_none(), "Metagame: standard tokens have no per-token renderer"); + assert!(skills_address.is_none(), "Metagame: standard tokens have no per-token skills"); + return IMinigameTokenDispatcher { contract_address: minigame_token_address } + .mint_batch_recipients( + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + recipients, + soulbound, + paymaster, + salt, + metadata, + ); + } + + assert_not_a_foreign_standard_token(minigame_token_address, game_address); + + // Legacy token: narrower metadata field — reject rather than truncate. + let legacy_metadata: u16 = metadata.try_into().expect('Metagame: metadata exceeds u16'); + IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address } + .mint_batch_recipients( + game_address, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + recipients, + soulbound, + paymaster, + salt, + legacy_metadata, + ) +} + +/// Mints multiple game tokens in batch through their games' token contracts +/// +/// Each entry names its own game; the token is resolved per mint. +/// +/// # Arguments +/// * `mints` - Array of mint parameters for each token +/// +/// # Returns +/// * `Array` - Array of minted token IDs +pub fn mint_batch(mints: Array) -> Array { let mut token_ids = array![]; let mut index = 0; @@ -156,7 +360,6 @@ pub fn mint_batch( }; let token_id = mint( - default_token_address, *mint_param.game_address, *mint_param.player_name, *mint_param.settings_id, @@ -191,17 +394,74 @@ pub fn calculate_game_fee(revenue: u128, fee_numerator: u16) -> u128 { result.try_into().unwrap() } -/// Resolves game fee info by navigating: game_address → token → registry → game_fee_info +/// Resolves game fee info. +/// +/// Standard tokens carry the fee terms the registry used to hold, so a token +/// advertising `IMINIGAME_TOKEN_GAME_FEE_ID` answers directly. Legacy tokens +/// keep the game_address → token → registry → game_fee_info walk. pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; let minigame_token_address = minigame_dispatcher.token_address(); - let minigame_token_dispatcher = IMinigameTokenDispatcher { + if supports_game_fee_surface(minigame_token_address) { + // The game-fee surface belongs to the self-bound standard token. + assert_self_bound(minigame_token_address, game_address); + let info = IMinigameTokenGameFeeDispatcher { contract_address: minigame_token_address } + .game_fee_terms(); + return GameFeeInfo { license: info.license, fee_numerator: info.fee_numerator }; + } + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); + if minigame_registry_address.is_zero() { + // Single-game legacy token: no registry to ask and no game-fee surface, + // so the game declares no fee anywhere. "Declares nothing" and + // "declares zero" mean the same thing — nobody is owed anything — so + // answer zero rather than reverting. This keeps the fee question total: + // `calculate_game_fee` returns 0 and `pay_game_fee` exits before + // transferring, so no caller has to pre-check. + // + // `assert_game_registered` admits this shape; without this guard the + // walk below dispatched at address 0 and reverted. + return GameFeeInfo { license: default_license(), fee_numerator: 0 }; + } let minigame_registry_dispatcher = IMinigameRegistryDispatcher { contract_address: minigame_registry_address, }; let game_id = minigame_registry_dispatcher.game_id_from_address(game_address); minigame_registry_dispatcher.game_fee_info(game_id) } + +/// True when the token exposes the standard game-fee surface +/// (`IMINIGAME_TOKEN_GAME_FEE_ID`) that replaced the registry's fee/payee role. +pub fn supports_game_fee_surface(token_address: ContractAddress) -> bool { + ISRC5Dispatcher { contract_address: token_address } + .supports_interface(IMINIGAME_TOKEN_GAME_FEE_ID) +} + +/// Resolves the address that should receive a game's fee. +/// +/// Standard tokens name the payee directly (`game_fee_recipient`). Legacy +/// registry tokens keep the old indirection: the payee is whoever currently +/// owns the game's registry NFT. +pub fn get_game_fee_recipient(game_address: ContractAddress) -> ContractAddress { + let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; + let token_address = minigame_dispatcher.token_address(); + if supports_game_fee_surface(token_address) { + // Same pairing check: a hostile game must not redirect the payee. + assert_self_bound(token_address, game_address); + return IMinigameTokenGameFeeDispatcher { contract_address: token_address } + .game_fee_recipient(); + } + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; + let registry_address = token_dispatcher.game_registry_address(); + // Unlike the fee, an absent PAYEE is not a benign zero. Reaching here means + // a caller is trying to pay a game that never named anyone, and paying the + // zero address would burn the funds. `get_game_fee_info` returns 0 for this + // shape, so `pay_game_fee` exits before reaching this — arriving anyway is + // a caller bug worth surfacing. + assert!(!registry_address.is_zero(), "Metagame: game declares no fee recipient"); + let registry_dispatcher = IMinigameRegistryDispatcher { contract_address: registry_address }; + let game_id = registry_dispatcher.game_id_from_address(game_address); + IERC721Dispatcher { contract_address: registry_address }.owner_of(game_id.into()) +} diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 8fb0d9d0..126a2e73 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -1,91 +1,31 @@ +/// Metagame Component /// -/// Game Component +/// Self-binding, like `MinigameTokenComponent`: the embedding contract IS the +/// metagame. It holds no addresses — no default token (each game brings its +/// own, resolved per mint) and no context address (a metagame that provides +/// context embeds `ContextComponent` itself, which registers +/// `IMETAGAME_CONTEXT_ID` on this same contract). /// +/// With no addresses left to expose, there is no `IMetagame` ABI: the +/// component is a set of internal helpers over `metagame::metagame` (`libs`), +/// each branching on SRC5 to serve both token generations. #[starknet::component] pub mod MetagameComponent { - use core::num::traits::Zero; - use game_components_embeddable_game_standard::metagame::extensions::context::interface::IMETAGAME_CONTEXT_ID; use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; - use game_components_embeddable_game_standard::token::interface::IMINIGAME_TOKEN_ID; + use game_components_interfaces::structs::token::MintBatchRecipient; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; - use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; - use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; - use openzeppelin_introspection::src5::SRC5Component; - use openzeppelin_introspection::src5::SRC5Component::{ - InternalTrait as SRC5InternalTrait, SRC5Impl, - }; use starknet::contract_address::ContractAddress; - use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; - use crate::metagame::interface::{IMETAGAME_ID, IMetagame}; use crate::metagame::metagame as libs; use crate::metagame::structs::MintMetagameParams; - use crate::minigame::interface::{IMinigameDispatcher, IMinigameDispatcherTrait}; - use crate::registry::interface::{IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait}; - use crate::token::interface::{IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait}; + /// Self-bound: no addresses to hold. #[storage] - pub struct Storage { - context_address: ContractAddress, - default_token_address: ContractAddress, - } - - #[embeddable_as(MetagameImpl)] - impl Metagame< - TContractState, - +HasComponent, - impl SRC5: SRC5Component::HasComponent, - +Drop, - > of IMetagame> { - fn context_address(self: @ComponentState) -> ContractAddress { - self.context_address.read() - } - - fn default_token_address(self: @ComponentState) -> ContractAddress { - self.default_token_address.read() - } - } + pub struct Storage {} #[generate_trait] pub impl InternalImpl< - TContractState, - +HasComponent, - impl SRC5: SRC5Component::HasComponent, - +Drop, + TContractState, +HasComponent, +Drop, > of InternalTrait { - fn initializer( - ref self: ComponentState, - context_address: Option, - default_token_address: ContractAddress, - ) { - self.register_src5_interfaces(); - match context_address { - Option::Some(context_address) => { - assert!(!context_address.is_zero(), "Metagame: Context address is zero"); - let context_src5_dispatcher = ISRC5Dispatcher { - contract_address: context_address, - }; - assert!( - context_src5_dispatcher.supports_interface(IMETAGAME_CONTEXT_ID), - "Metagame: Context contract does not support IMetagameContext", - ); - self.context_address.write(context_address); - }, - Option::None => {}, - } - assert!(!default_token_address.is_zero(), "Metagame: Default token address is zero"); - let minigame_dispatcher = ISRC5Dispatcher { contract_address: default_token_address }; - assert!( - minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_ID), - "Metagame: Default token contract does not support IMinigameToken", - ); - self.default_token_address.write(default_token_address); - } - - fn register_src5_interfaces(ref self: ComponentState) { - let mut src5_component = get_dep_component_mut!(ref self, SRC5); - src5_component.register_interface(IMETAGAME_ID); - } - fn assert_game_registered( ref self: ComponentState, game_address: ContractAddress, ) { @@ -94,7 +34,7 @@ pub mod MetagameComponent { fn mint( ref self: ComponentState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -108,10 +48,9 @@ pub mod MetagameComponent { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { libs::mint( - self.default_token_address.read(), game_address, player_name, settings_id, @@ -133,11 +72,55 @@ pub mod MetagameComponent { fn mint_batch( ref self: ComponentState, mints: Array, ) -> Array { - libs::mint_batch(self.default_token_address.read(), mints) + libs::mint_batch(mints) } - /// Reads fee from registry, calculates amount, transfers via ERC20 - /// to the game's creator token owner. Returns fee amount (0 if no fee). + /// Many tokens for ONE game in a single dispatch, via the token's own + /// `mint_batch_recipients`. Prefer this over `mint_batch` whenever the + /// batch shares a game: `mint_batch` costs one cross-contract call per + /// token and re-serialises `context` each time. + fn mint_batch_recipients( + ref self: ComponentState, + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> Array { + libs::mint_batch_recipients( + game_address, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + recipients, + soulbound, + paymaster, + salt, + metadata, + ) + } + + /// Resolves the game's fee terms and pays them via ERC20. Returns the + /// amount paid, 0 if the game charges nothing — a game that declares no + /// fee at all answers zero too, so this short-circuits before it ever + /// needs a recipient. Terms and recipient come from the token's game-fee + /// surface for standard tokens, from the registry for legacy ones. fn pay_game_fee( ref self: ComponentState, game_address: ContractAddress, @@ -150,23 +133,17 @@ pub mod MetagameComponent { return 0; } - // Get the creator token owner (fee recipient) - let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; - let token_address = minigame_dispatcher.token_address(); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; - let registry_address = token_dispatcher.game_registry_address(); - let registry_dispatcher = IMinigameRegistryDispatcher { - contract_address: registry_address, - }; - let game_id = registry_dispatcher.game_id_from_address(game_address); + // Resolve the fee recipient: standard tokens name the payee + // directly, legacy registry tokens go through the registry NFT's + // current owner. + let recipient = libs::get_game_fee_recipient(game_address); - // Get creator token owner via ERC721 owner_of - let erc721 = IERC721Dispatcher { contract_address: registry_address }; - let recipient = erc721.owner_of(game_id.into()); - - // Transfer fee + // Transfer fee. ERC20s that signal failure by returning false + // instead of reverting must not be reported as a paid fee. let erc20 = IERC20Dispatcher { contract_address: payment_token }; - erc20.transfer(recipient, fee_amount.into()); + assert!( + erc20.transfer(recipient, fee_amount.into()), "Metagame: game fee transfer failed", + ); fee_amount } diff --git a/packages/embeddable_game_standard/src/metagame/structs.cairo b/packages/embeddable_game_standard/src/metagame/structs.cairo index 2bf8fe00..497e4630 100644 --- a/packages/embeddable_game_standard/src/metagame/structs.cairo +++ b/packages/embeddable_game_standard/src/metagame/structs.cairo @@ -4,7 +4,7 @@ use starknet::ContractAddress; /// Parameters for minting a token in batch operations through metagame #[derive(Drop, Serde)] pub struct MintMetagameParams { - pub game_address: Option, + pub game_address: ContractAddress, pub player_name: Option, pub settings_id: Option, pub start: Option, @@ -18,5 +18,8 @@ pub struct MintMetagameParams { pub soulbound: bool, pub paymaster: bool, pub salt: u16, - pub metadata: u16, + /// Inert data the game interprets. `u128` to reach the standard token's + /// 65-bit field; a legacy mint asserts the value fits its u16 field + /// rather than truncating. + pub metadata: u128, } diff --git a/packages/embeddable_game_standard/src/metagame/tests.cairo b/packages/embeddable_game_standard/src/metagame/tests.cairo index 4ea39a8d..2061b0b5 100644 --- a/packages/embeddable_game_standard/src/metagame/tests.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests.cairo @@ -1,3 +1,4 @@ +mod test_batch_recipients; mod test_callback; mod test_context_component; mod test_fuzz_mint_parameters; diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo new file mode 100644 index 00000000..1fadd5b0 --- /dev/null +++ b/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo @@ -0,0 +1,371 @@ +// ============================================================================= +// TEST: mint_batch_recipients — ONE game, many recipients, ONE dispatch +// ============================================================================= +// +// Distinct from `mint_batch`, which loops over `mint`: one cross-contract call +// per token, each entry free to name a different game. This routes a single +// dispatch to the token's own batch entrypoint, which hoists the +// batch-invariant work and runs one global salt counter. Tournament entry is +// the motivating case — budokan mints every entrant in one call, and adopting +// `mint_batch` there would have turned that into N calls. + +use game_components_embeddable_game_standard::token::interface::{ + IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +}; +use game_components_interfaces::structs::token::MintBatchRecipient; +use game_components_testing::constants::{ALICE, BOB, OWNER}; +use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; +use starknet::ContractAddress; +use crate::metagame::metagame as libs; + +// ============================================================================= +// HELPERS +// ============================================================================= + +/// The merged game+token contract — the only supported standard-token shape. +fn deploy_standard_game() -> ContractAddress { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + ALICE().serialize(ref calldata); + OWNER().serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + contract_address +} + +fn deploy_legacy_token() -> ContractAddress { + let contract = declare("MockMinigameTokenForLibs").unwrap().contract_class(); + let (address, _) = contract.deploy(@array![]).unwrap(); + address +} + +fn deploy_legacy_game(token_address: ContractAddress) -> ContractAddress { + let contract = declare("MockMinigameForLibs").unwrap().contract_class(); + let (address, _) = contract.deploy(@array![token_address.into()]).unwrap(); + address +} + +/// Two recipients, three tokens total — exercises per-recipient counts. +fn two_recipients() -> Array { + array![MintBatchRecipient { to: ALICE(), count: 2 }, MintBatchRecipient { to: BOB(), count: 1 }] +} + +fn one_recipient() -> Array { + array![MintBatchRecipient { to: BOB(), count: 1 }] +} + +// ============================================================================= +// STANDARD TOKEN +// ============================================================================= + +/// One dispatch mints every recipient's tokens, in recipient order. +#[test] +fn test_batch_recipients_through_standard_token() { + let game = deploy_standard_game(); + + let token_ids = libs::mint_batch_recipients( + game, + Option::Some('Entrant'), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + two_recipients(), + false, + false, + 0, + 0, + ); + + assert!(token_ids.len() == 3, "expected 3 tokens, got {}", token_ids.len()); + let erc721 = IERC721Dispatcher { contract_address: game }; + assert!(erc721.owner_of((*token_ids.at(0)).into()) == ALICE(), "token 0 should go to ALICE"); + assert!(erc721.owner_of((*token_ids.at(1)).into()) == ALICE(), "token 1 should go to ALICE"); + assert!(erc721.owner_of((*token_ids.at(2)).into()) == BOB(), "token 2 should go to BOB"); +} + +/// Every token in the batch is distinct — the global salt counter runs across +/// recipients, not per recipient. +#[test] +fn test_batch_recipients_ids_are_distinct() { + let game = deploy_standard_game(); + + let token_ids = libs::mint_batch_recipients( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + two_recipients(), + false, + false, + 0, + 0, + ); + + let a = *token_ids.at(0); + let b = *token_ids.at(1); + let c = *token_ids.at(2); + assert!(a != b, "tokens 0 and 1 collided"); + assert!(b != c, "tokens 1 and 2 collided"); + assert!(a != c, "tokens 0 and 2 collided"); +} + +/// The reason this parameter is `u128`: budokan passes a metadata_value wider +/// than the legacy token's u16 field, and it must survive the batch unchanged. +#[test] +fn test_batch_recipients_carries_wide_metadata() { + let game = deploy_standard_game(); + // Wider than u16, well inside the id layout's 65-bit metadata field. + let wide: u128 = 0x100000000; + + let token_ids = libs::mint_batch_recipients( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + one_recipient(), + false, + false, + 0, + wide, + ); + + let token = IMinigameTokenDispatcher { contract_address: game }; + assert!(token.mint_metadata(*token_ids.at(0)) == wide, "wide metadata lost in the batch"); +} + +/// Same self-bound gate as every other standard-token path: a contract that +/// merely implements `token_address()` must not mint on a token it does not own. +#[test] +#[should_panic(expected: "Game is not registered")] +fn test_batch_recipients_rejects_foreign_standard_token() { + let victim = deploy_standard_game(); + let hostile: ContractAddress = 0xBAD.try_into().unwrap(); + mock_call(hostile, selector!("token_address"), victim, 10); + + libs::mint_batch_recipients( + hostile, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + one_recipient(), + false, + false, + 0, + 0, + ); +} + +/// Unsupported params are rejected loudly, never silently dropped. +#[test] +#[should_panic(expected: "Metagame: standard tokens have no per-token renderer")] +fn test_batch_recipients_rejects_renderer_on_standard_token() { + let game = deploy_standard_game(); + libs::mint_batch_recipients( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(BOB()), + Option::None, + one_recipient(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "Metagame: standard tokens have no per-token skills")] +fn test_batch_recipients_rejects_skills_on_standard_token() { + let game = deploy_standard_game(); + libs::mint_batch_recipients( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(BOB()), + one_recipient(), + false, + false, + 0, + 0, + ); +} + +// ============================================================================= +// LEGACY TOKEN +// ============================================================================= + +/// Legacy tokens are served too, through their own batch entrypoint. +#[test] +fn test_batch_recipients_through_legacy_token() { + let token_address = deploy_legacy_token(); + let game_address = deploy_legacy_game(token_address); + + let token_ids = libs::mint_batch_recipients( + game_address, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + two_recipients(), + false, + false, + 0, + 0, + ); + + assert!(token_ids.len() == 3, "expected 3 tokens, got {}", token_ids.len()); +} + +/// Legacy keeps its renderer/skills params — they are only unsupported on the +/// standard token, so the legacy path must still accept them. +#[test] +fn test_batch_recipients_accepts_renderer_on_legacy_token() { + let token_address = deploy_legacy_token(); + let game_address = deploy_legacy_game(token_address); + + let token_ids = libs::mint_batch_recipients( + game_address, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(BOB()), + Option::None, + one_recipient(), + false, + false, + 0, + 0, + ); + + assert!(token_ids.len() == 1, "legacy batch should accept a renderer"); +} + +/// The legacy metadata field is u16 — reject rather than silently truncate. +#[test] +#[should_panic(expected: ('Metagame: metadata exceeds u16',))] +fn test_batch_recipients_rejects_wide_metadata_on_legacy_token() { + let token_address = deploy_legacy_token(); + let game_address = deploy_legacy_game(token_address); + + libs::mint_batch_recipients( + game_address, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + one_recipient(), + false, + false, + 0, + 0x100000000, + ); +} + +/// The single-mint path narrows the same way the batch path does — the two +/// must not disagree about what a legacy token accepts. +#[test] +#[should_panic(expected: ('Metagame: metadata exceeds u16',))] +fn test_mint_rejects_wide_metadata_on_legacy_token() { + let token_address = deploy_legacy_token(); + let game_address = deploy_legacy_game(token_address); + + libs::mint( + game_address, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + BOB(), + false, + false, + 0, + 0x100000000, + ); +} + +/// A metadata value that does fit u16 passes through to the legacy token. +#[test] +fn test_batch_recipients_accepts_narrow_metadata_on_legacy_token() { + let token_address = deploy_legacy_token(); + let game_address = deploy_legacy_game(token_address); + + let token_ids = libs::mint_batch_recipients( + game_address, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + one_recipient(), + false, + false, + 0, + 0xFFFF, + ); + + assert!(token_ids.len() == 1, "u16-fitting metadata should pass through"); +} diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo index 8d766e68..09bdf465 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo @@ -38,11 +38,10 @@ fn TOKEN_ADDRESS() -> ContractAddress { fn deploy_callback_contract() -> (ContractAddress, IMetagameCallbackDispatcher, ContractAddress) { let token_address = TOKEN_ADDRESS(); - // Mock supports_interface so MetagameComponent::initializer passes SRC5 check mock_call(token_address, selector!("supports_interface"), true, 100); let contract = declare("MockCallbackContract").unwrap().contract_class(); - // Constructor args: Option::None for context_address, then default_token_address + // Constructor args: Option::None for context_address, then the callback's token let mut calldata = array![]; calldata.append(1); // Option::None calldata.append(token_address.into()); @@ -466,8 +465,6 @@ mod MockCallbackContract { MetagameCallbackComponent::MetagameCallbackImpl; impl CallbackInternalImpl = MetagameCallbackComponent::InternalImpl; - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -507,8 +504,7 @@ mod MockCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, default_token_address); - self.callback.initializer(); + self.callback.initializer(default_token_address); } // View functions for test assertions @@ -562,8 +558,6 @@ mod MockEmptyCallbackContract { MetagameCallbackComponent::MetagameCallbackImpl; impl CallbackInternalImpl = MetagameCallbackComponent::InternalImpl; - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -596,7 +590,20 @@ mod MockEmptyCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, default_token_address); - self.callback.initializer(); + self.callback.initializer(default_token_address); } } + + +// The callback's token binding is its own — a zero address would leave +// `assert_only_token` gating on nothing. +#[test] +fn test_callback_initializer_rejects_zero_token() { + let contract = declare("MockCallbackContract").unwrap().contract_class(); + let mut calldata = array![]; + calldata.append(1); // Option::None context_address + calldata.append(0); // zero token address + // The constructor panic surfaces as a deploy error, so assert on the + // Result rather than unwrapping it. + assert!(contract.deploy(@calldata).is_err(), "zero token address must be rejected"); +} diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_fuzz_mint_parameters.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_fuzz_mint_parameters.cairo index 688be7d3..d7a5ce75 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_fuzz_mint_parameters.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_fuzz_mint_parameters.cairo @@ -1,5 +1,5 @@ -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; use starknet::ContractAddress; @@ -10,7 +10,7 @@ use crate::metagame::extensions::context::structs::GameContextDetails; trait IMockMetagame { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -24,7 +24,7 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -47,7 +47,7 @@ fn test_fuzz_mint_parameters() { let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); let metagame_dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; // Fuzz test different timestamp combinations let test_cases = array![ @@ -107,7 +107,7 @@ fn test_fuzz_player_names() { let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); let metagame_dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; // Test various player names as felt252 shortstrings (max 31 chars) let test_names: Array = array![ @@ -132,7 +132,7 @@ fn test_fuzz_player_names() { // Mint with this player name let token_id = metagame_dispatcher .mint( - Option::Some(minigame_address), + minigame_address, Option::Some(name), Option::None, Option::None, @@ -190,7 +190,7 @@ fn test_property_token_id_monotonicity() { let token_id = metagame_dispatcher .mint( - Option::Some(minigame_address), + minigame_address, Option::None, Option::None, Option::None, @@ -231,7 +231,7 @@ fn try_mint_with_lifecycle( let token_id = dispatcher .mint( - Option::Some(game_address), + game_address, Option::None, Option::None, Option::Some(start), @@ -262,8 +262,6 @@ mod MockMetagameFuzz { component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); component!(path: SRC5Component, storage: src5, event: SRC5Event); - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[storage] @@ -288,16 +286,14 @@ mod MockMetagameFuzz { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -311,7 +307,7 @@ mod MockMetagameFuzz { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -476,10 +472,10 @@ mod MockMinigameFuzz { mod MockMinigameTokenFuzz { use core::num::traits::Zero; use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameToken, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, }; - use game_components_embeddable_game_standard::token::structs::{ + use game_components_embeddable_game_standard::token_legacy::structs::{ Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; @@ -507,7 +503,7 @@ mod MockMinigameTokenFuzz { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -825,7 +821,7 @@ mod MockMinigameTokenFuzz { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo index 315fbec5..edbb347c 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -3,8 +3,8 @@ // ============================================================================= // Tests for library functions: assert_game_registered, mint, mint_batch -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; use game_components_testing::constants::{ALICE, BOB}; use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; @@ -56,17 +56,17 @@ fn sample_context() -> GameContextDetails { } // ============================================================================= -// MINT TESTS - DEFAULT TOKEN PATH (game_address = None) +// MINT TESTS - GAME PATH (the token is resolved from game_address) // ============================================================================= // LIB-MINT-01: Mint with only required params #[test] -fn test_mint_default_token_minimal() { +fn test_mint_through_game_minimal() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, // game_address + game_address, Option::None, // player_name Option::None, // settings_id Option::None, // start @@ -88,12 +88,12 @@ fn test_mint_default_token_minimal() { // LIB-MINT-02: Mint with player name #[test] -fn test_mint_default_token_with_player_name() { +fn test_mint_through_game_with_player_name() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::Some('Player1'), Option::None, Option::None, @@ -110,19 +110,19 @@ fn test_mint_default_token_with_player_name() { 0, ); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let player_name = token_dispatcher.player_name(token_id); assert!(player_name == 'Player1', "Player name mismatch"); } // LIB-MINT-03: Mint with settings_id #[test] -fn test_mint_default_token_with_settings() { +fn test_mint_through_game_with_settings() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::Some(42), Option::None, @@ -144,12 +144,12 @@ fn test_mint_default_token_with_settings() { // LIB-MINT-04: Mint with lifecycle (start/end) #[test] -fn test_mint_default_token_with_lifecycle() { +fn test_mint_through_game_with_lifecycle() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::Some(1000), @@ -166,7 +166,7 @@ fn test_mint_default_token_with_lifecycle() { 0, ); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let metadata = token_dispatcher.token_metadata(token_id); assert!(metadata.lifecycle.start == 1000, "Start time mismatch"); assert!(metadata.lifecycle.end == 2000, "End time mismatch"); @@ -174,12 +174,12 @@ fn test_mint_default_token_with_lifecycle() { // LIB-MINT-05: Mint with objective_id #[test] -fn test_mint_default_token_with_objective() { +fn test_mint_through_game_with_objective() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -201,13 +201,13 @@ fn test_mint_default_token_with_objective() { // LIB-MINT-06: Mint with context #[test] -fn test_mint_default_token_with_context() { +fn test_mint_through_game_with_context() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let context = sample_context(); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -229,12 +229,12 @@ fn test_mint_default_token_with_context() { // LIB-MINT-07: Mint with client URL #[test] -fn test_mint_default_token_with_client_url() { +fn test_mint_through_game_with_client_url() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -256,13 +256,13 @@ fn test_mint_default_token_with_client_url() { // LIB-MINT-08: Mint with renderer address #[test] -fn test_mint_default_token_with_renderer() { +fn test_mint_through_game_with_renderer() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let renderer: ContractAddress = 0xBEEF.try_into().unwrap(); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -284,12 +284,12 @@ fn test_mint_default_token_with_renderer() { // LIB-MINT-09: Mint soulbound token #[test] -fn test_mint_default_token_soulbound() { +fn test_mint_through_game_soulbound() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -311,14 +311,14 @@ fn test_mint_default_token_soulbound() { // LIB-MINT-10: Mint with all parameters #[test] -fn test_mint_default_token_all_params() { +fn test_mint_through_game_all_params() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let renderer: ContractAddress = 0xBEEF.try_into().unwrap(); let context = sample_context(); let token_id = libs::mint( - token_address, - Option::None, // game_address (using default token path) + game_address, Option::Some('FullPlayer'), Option::Some(99), Option::Some(1000), @@ -337,7 +337,7 @@ fn test_mint_default_token_all_params() { assert!(token_id != 0, "Token should be minted with all params"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let player_name = token_dispatcher.player_name(token_id); assert!(player_name == 'FullPlayer', "Player name mismatch"); } @@ -353,8 +353,7 @@ fn test_mint_game_token_routes_through_game() { let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, // default_token_address (not used when game_address is provided) - Option::Some(game_address), + game_address, Option::Some('GamePlayer'), Option::None, Option::None, @@ -373,7 +372,7 @@ fn test_mint_game_token_routes_through_game() { assert!(token_id != 0, "Token should be minted through game"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let player_name = token_dispatcher.player_name(token_id); assert!(player_name == 'GamePlayer', "Player name should be preserved"); } @@ -386,8 +385,7 @@ fn test_mint_game_token_preserves_params() { let context = sample_context(); let token_id = libs::mint( - token_address, - Option::Some(game_address), + game_address, Option::Some('GamePlayer2'), Option::Some(42), Option::Some(500), @@ -406,7 +404,7 @@ fn test_mint_game_token_preserves_params() { assert!(token_id != 0, "Token should be minted with all params through game"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let metadata = token_dispatcher.token_metadata(token_id); assert!(metadata.lifecycle.start == 500, "Start time should be preserved"); assert!(metadata.lifecycle.end == 1500, "End time should be preserved"); @@ -420,10 +418,10 @@ fn test_mint_game_token_preserves_params() { #[test] fn test_mint_instant_game() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::Some(100), @@ -442,7 +440,7 @@ fn test_mint_instant_game() { assert!(token_id != 0, "Instant game token should be minted"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let metadata = token_dispatcher.token_metadata(token_id); assert!(metadata.lifecycle.start == metadata.lifecycle.end, "Start should equal end"); } @@ -454,10 +452,8 @@ fn test_mint_instant_game() { // LIB-BATCH-01: Empty batch #[test] fn test_mint_batch_empty_array() { - let token_address = deploy_mock_minigame_token(); - let mints: Array = array![]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 0, "Empty batch should return empty array"); } @@ -466,10 +462,11 @@ fn test_mint_batch_empty_array() { #[test] fn test_mint_batch_single_mint() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('BatchPlayer1'), settings_id: Option::None, start: Option::None, @@ -487,7 +484,7 @@ fn test_mint_batch_single_mint() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 1, "Should return 1 token ID"); assert!(*token_ids.at(0) == 1.into(), "First token ID should be 1"); @@ -497,10 +494,11 @@ fn test_mint_batch_single_mint() { #[test] fn test_mint_batch_multiple_mints() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Player1'), settings_id: Option::None, start: Option::None, @@ -517,7 +515,7 @@ fn test_mint_batch_multiple_mints() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Player2'), settings_id: Option::None, start: Option::None, @@ -534,7 +532,7 @@ fn test_mint_batch_multiple_mints() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Player3'), settings_id: Option::None, start: Option::None, @@ -552,7 +550,7 @@ fn test_mint_batch_multiple_mints() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 3, "Should return 3 token IDs"); assert!(*token_ids.at(0) == 1.into(), "First token ID should be 1"); @@ -564,10 +562,11 @@ fn test_mint_batch_multiple_mints() { #[test] fn test_mint_batch_preserves_order() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('First'), settings_id: Option::None, start: Option::Some(100), @@ -584,7 +583,7 @@ fn test_mint_batch_preserves_order() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Second'), settings_id: Option::None, start: Option::Some(200), @@ -602,9 +601,9 @@ fn test_mint_batch_preserves_order() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; // Verify first token let first_name = token_dispatcher.player_name(*token_ids.at(0)); @@ -619,11 +618,12 @@ fn test_mint_batch_preserves_order() { #[test] fn test_mint_batch_with_context() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let context = sample_context(); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('ContextPlayer'), settings_id: Option::None, start: Option::None, @@ -641,7 +641,7 @@ fn test_mint_batch_with_context() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 1, "Should mint token with context"); } @@ -650,10 +650,11 @@ fn test_mint_batch_with_context() { #[test] fn test_mint_batch_with_client_url() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::None, settings_id: Option::None, start: Option::None, @@ -671,7 +672,7 @@ fn test_mint_batch_with_client_url() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 1, "Should mint token with client URL"); } @@ -685,10 +686,10 @@ fn test_mint_batch_with_client_url() { #[fuzzer(runs: 100)] fn test_fuzz_mint_player_names(player_name: felt252) { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::Some(player_name), Option::None, Option::None, @@ -707,7 +708,7 @@ fn test_fuzz_mint_player_names(player_name: felt252) { assert!(token_id != 0, "Token should be minted"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; let retrieved_name = token_dispatcher.player_name(token_id); assert!(retrieved_name == player_name, "Player name should be preserved"); } @@ -717,10 +718,10 @@ fn test_fuzz_mint_player_names(player_name: felt252) { #[fuzzer(runs: 100)] fn test_fuzz_mint_settings_ids(settings_id: u32) { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::Some(settings_id), Option::None, @@ -745,10 +746,10 @@ fn test_fuzz_mint_settings_ids(settings_id: u32) { #[fuzzer(runs: 100)] fn test_fuzz_mint_objective_ids(objective_id: u32) { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let token_id = libs::mint( - token_address, - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -777,10 +778,10 @@ fn test_fuzz_mint_objective_ids(objective_id: u32) { mod MockMinigameTokenForLibs { use core::num::traits::Zero; use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameToken, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, }; - use game_components_embeddable_game_standard::token::structs::{ + use game_components_embeddable_game_standard::token_legacy::structs::{ Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; @@ -807,7 +808,7 @@ mod MockMinigameTokenForLibs { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -1087,7 +1088,28 @@ mod MockMinigameTokenForLibs { salt: u16, metadata: u16, ) -> Array { - panic!("not implemented") + // Minimal but real: one id per requested count, in recipient order, + // recording the paired game — so the metagame lib's legacy batch + // path is genuinely exercised rather than stubbed out. + let mut token_ids = array![]; + let mut i = 0; + while i < recipients.len() { + let recipient = *recipients.at(i); + let mut n: u16 = 0; + while n < recipient.count { + let token_id_u64 = self.next_token_id.read(); + self.next_token_id.write(token_id_u64 + 1); + let token_id: felt252 = token_id_u64.into(); + self.token_game_address.write(token_id, game_address); + if let Option::Some(name) = player_name { + self.token_player_names.write(token_id, name); + } + token_ids.append(token_id); + n += 1; + } + i += 1; + } + token_ids } fn update_game(ref self: ContractState, token_id: felt252) {} @@ -1105,7 +1127,7 @@ mod MockMinigameTokenForLibs { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } @@ -1380,6 +1402,9 @@ fn test_assert_game_registered_success() { let registry_address: ContractAddress = 0x333.try_into().unwrap(); mock_call(game_address, selector!("token_address"), token_address, 1); + // Not a standard token: the SRC5 probe for IMINIGAME_TOKEN_ID answers + // false, so the check falls through to the registry path. + mock_call(token_address, selector!("supports_interface"), false, 1); mock_call(token_address, selector!("game_registry_address"), registry_address, 1); mock_call(registry_address, selector!("is_game_registered"), true, 1); @@ -1395,6 +1420,8 @@ fn test_assert_game_registered_fails_for_unregistered() { let registry_address: ContractAddress = 0x666.try_into().unwrap(); mock_call(game_address, selector!("token_address"), token_address, 1); + // Not a standard token — falls through to the registry path. + mock_call(token_address, selector!("supports_interface"), false, 1); mock_call(token_address, selector!("game_registry_address"), registry_address, 1); mock_call(registry_address, selector!("is_game_registered"), false, 1); @@ -1409,12 +1436,13 @@ fn test_assert_game_registered_fails_for_unregistered() { #[test] fn test_mint_batch_mixed_game_addresses() { let token_address = deploy_mock_minigame_token(); - let game_address = deploy_mock_minigame(token_address); + let game_a = deploy_mock_minigame(token_address); + let game_b = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::Some(game_address), - player_name: Option::Some('WithGame'), + game_address: game_a, + player_name: Option::Some('GameA'), settings_id: Option::None, start: Option::None, end: Option::None, @@ -1430,8 +1458,8 @@ fn test_mint_batch_mixed_game_addresses() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, - player_name: Option::Some('NoGame'), + game_address: game_b, + player_name: Option::Some('GameB'), settings_id: Option::None, start: Option::None, end: Option::None, @@ -1448,27 +1476,36 @@ fn test_mint_batch_mixed_game_addresses() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 2, "Should return 2 token IDs"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; - let first_game_addr = token_dispatcher.token_game_address(*token_ids.at(0)); - assert!(first_game_addr == game_address, "First token should have game address"); - - let second_name = token_dispatcher.player_name(*token_ids.at(1)); - assert!(second_name == 'NoGame', "Second token should have NoGame name"); + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; + // Each entry mints against its own game. + assert!( + token_dispatcher.token_game_address(*token_ids.at(0)) == game_a, + "First token should carry game A", + ); + assert!( + token_dispatcher.token_game_address(*token_ids.at(1)) == game_b, + "Second token should carry game B", + ); + assert!( + token_dispatcher.player_name(*token_ids.at(1)) == 'GameB', + "Second token should have GameB name", + ); } // LIB-BATCH-09: Batch with different recipients #[test] fn test_mint_batch_different_recipients() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let carol: ContractAddress = 0xCAFE.try_into().unwrap(); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Alice'), settings_id: Option::None, start: Option::None, @@ -1485,7 +1522,7 @@ fn test_mint_batch_different_recipients() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Bob'), settings_id: Option::None, start: Option::None, @@ -1502,7 +1539,7 @@ fn test_mint_batch_different_recipients() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Carol'), settings_id: Option::None, start: Option::None, @@ -1520,7 +1557,7 @@ fn test_mint_batch_different_recipients() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 3, "Should mint 3 tokens"); assert!(*token_ids.at(0) == 1.into(), "First ID should be 1"); @@ -1532,10 +1569,11 @@ fn test_mint_batch_different_recipients() { #[test] fn test_mint_batch_mixed_soulbound() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Transferable'), settings_id: Option::None, start: Option::None, @@ -1552,7 +1590,7 @@ fn test_mint_batch_mixed_soulbound() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('Soulbound'), settings_id: Option::None, start: Option::None, @@ -1570,7 +1608,7 @@ fn test_mint_batch_mixed_soulbound() { }, ]; - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 2, "Should mint 2 tokens with mixed soulbound"); } @@ -1578,6 +1616,7 @@ fn test_mint_batch_mixed_soulbound() { #[test] fn test_mint_batch_large_batch() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); let mut mints: Array = array![]; let mut i: u32 = 0; @@ -1588,7 +1627,7 @@ fn test_mint_batch_large_batch() { mints .append( MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::None, settings_id: Option::Some(i), start: Option::None, @@ -1608,7 +1647,7 @@ fn test_mint_batch_large_batch() { i += 1; } - let token_ids = libs::mint_batch(token_address, mints); + let token_ids = libs::mint_batch(mints); assert!(token_ids.len() == 50, "Should mint 50 tokens"); assert!(*token_ids.at(0) == 1.into(), "First ID should be 1"); @@ -1624,10 +1663,10 @@ fn test_mint_batch_large_batch() { mod MockMinigameTokenWithRegistry { use core::num::traits::Zero; use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameToken, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, }; - use game_components_embeddable_game_standard::token::structs::{ + use game_components_embeddable_game_standard::token_legacy::structs::{ Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; @@ -1655,7 +1694,7 @@ mod MockMinigameTokenWithRegistry { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -1914,7 +1953,28 @@ mod MockMinigameTokenWithRegistry { salt: u16, metadata: u16, ) -> Array { - panic!("not implemented") + // Minimal but real: one id per requested count, in recipient order, + // recording the paired game — so the metagame lib's legacy batch + // path is genuinely exercised rather than stubbed out. + let mut token_ids = array![]; + let mut i = 0; + while i < recipients.len() { + let recipient = *recipients.at(i); + let mut n: u16 = 0; + while n < recipient.count { + let token_id_u64 = self.next_token_id.read(); + self.next_token_id.write(token_id_u64 + 1); + let token_id: felt252 = token_id_u64.into(); + self.token_game_address.write(token_id, game_address); + if let Option::Some(name) = player_name { + self.token_player_names.write(token_id, name); + } + token_ids.append(token_id); + n += 1; + } + i += 1; + } + token_ids } fn update_game(ref self: ContractState, token_id: felt252) {} @@ -1932,8 +1992,399 @@ mod MockMinigameTokenWithRegistry { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } } + +// ============================================================================= +// STANDARD (SELF-BOUND) TOKEN PATHS +// ============================================================================= +// +// `assert_game_registered` accepts a self-bound standard token, so every +// downstream lib path must be able to serve one too. These run against the +// real merged game+token contract (`StandardGameMock`), not a mock ABI. + +#[cfg(test)] +mod standard_token_paths { + use game_components_embeddable_game_standard::token::interface::{ + IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, + }; + use game_components_interfaces::token::game_fee::{ + IMinigameTokenGameFeeDispatcher, IMinigameTokenGameFeeDispatcherTrait, + }; + use game_components_testing::constants::{ALICE, BOB, OWNER}; + use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; + use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; + use starknet::ContractAddress; + use crate::metagame::metagame as libs; + + /// One contract that is both the game and the standard token. + fn deploy_standard_game(game_fee_recipient: ContractAddress) -> ContractAddress { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + game_fee_recipient.serialize(ref calldata); + OWNER().serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + contract_address + } + + /// The self-bound pairing is what "registered" means for a standard token. + #[test] + fn test_assert_game_registered_accepts_standard_token() { + let game = deploy_standard_game(ALICE()); + libs::assert_game_registered(game); + } + + /// Regression: the gate accepted standard tokens while `mint` still spoke + /// the legacy 15-arg ABI, so every accepted game reverted at mint. + #[test] + fn test_mint_through_standard_token() { + let game = deploy_standard_game(ALICE()); + + let token_id = libs::mint( + game, + Option::Some('player'), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, // renderer — unsupported, must be None + Option::None, // skills — unsupported, must be None + BOB(), + false, + false, + 0, + 0, + ); + + assert!(token_id != 0, "mint returned a zero token id"); + let erc721 = IERC721Dispatcher { contract_address: game }; + assert!(erc721.owner_of(token_id.into()) == BOB(), "token not minted to recipient"); + let token = IMinigameTokenDispatcher { contract_address: game }; + assert!(token.player_name(token_id) == 'player', "player name not stored"); + } + + /// `metadata` is u128 so the single-mint path reaches the same 65-bit field + /// the batch path does — a u16 here would silently narrow a consumer that + /// threads a wider value through. + #[test] + fn test_mint_carries_wide_metadata() { + let game = deploy_standard_game(ALICE()); + let wide: u128 = 0x100000000; + + let token_id = libs::mint( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + BOB(), + false, + false, + 0, + wide, + ); + + let token = IMinigameTokenDispatcher { contract_address: game }; + assert!(token.mint_metadata(token_id) == wide, "wide metadata lost on the single mint"); + } + + /// Minimal mint through a standard game — every optional param None. + #[test] + fn test_mint_standard_token_minimal() { + let game = deploy_standard_game(ALICE()); + + let token_id = libs::mint( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + BOB(), + false, + false, + 0, + 0, + ); + + let erc721 = IERC721Dispatcher { contract_address: game }; + assert!(erc721.owner_of(token_id.into()) == BOB(), "token not minted to recipient"); + } + + /// Unsupported params are rejected loudly, never silently dropped. + #[test] + #[should_panic(expected: "Metagame: standard tokens have no per-token renderer")] + fn test_mint_rejects_renderer_on_standard_token() { + let game = deploy_standard_game(ALICE()); + libs::mint( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(BOB()), + Option::None, + BOB(), + false, + false, + 0, + 0, + ); + } + + #[test] + #[should_panic(expected: "Metagame: standard tokens have no per-token skills")] + fn test_mint_rejects_skills_on_standard_token() { + let game = deploy_standard_game(ALICE()); + libs::mint( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(BOB()), + BOB(), + false, + false, + 0, + 0, + ); + } + + /// The game-fee surface replaces the registry's fee role — previously this + /// reverted on the missing `game_registry_address()` entrypoint. + #[test] + fn test_get_game_fee_info_reads_game_fee_surface() { + let game = deploy_standard_game(ALICE()); + let fee_info = libs::get_game_fee_info(game); + let declared = IMinigameTokenGameFeeDispatcher { contract_address: game }; + assert!( + fee_info.fee_numerator == declared.game_fee_terms().fee_numerator, + "fee numerator does not match the token's declared fee", + ); + } + + /// The payee is named directly, not resolved through a registry NFT owner. + #[test] + fn test_get_game_fee_recipient_is_the_declared_payee() { + let game = deploy_standard_game(ALICE()); + assert!( + libs::get_game_fee_recipient(game) == ALICE(), + "payee is not the declared fee recipient", + ); + } +} + +// ============================================================================= +// SINGLE-GAME LEGACY TOKEN (ZERO REGISTRY) +// ============================================================================= +// +// A legacy token is a SEPARATE contract from its game, so with no registry the +// pairing is the token's own `game_address()` view — not an address equality +// against the token itself. This shape previously dispatched into address 0 +// and reverted with CONTRACT_NOT_DEPLOYED. + +#[cfg(test)] +mod legacy_single_game_token { + use core::num::traits::Zero; + use game_components_testing::constants::{ALICE, BOB}; + use snforge_std::mock_call; + use starknet::ContractAddress; + use crate::metagame::metagame as libs; + use super::{deploy_mock_minigame_for_registry, deploy_mock_token_with_registry}; + + #[test] + fn test_assert_game_registered_accepts_paired_single_game_token() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + // The token names its paired game — that is the pairing to check. + mock_call(token, selector!("game_address"), game, 1); + + libs::assert_game_registered(game); + } + + /// Regression: the gate admits this shape, but the fee path walked to the + /// registry without a zero guard and reverted dispatching at address 0. + /// A game that declares no fee anywhere is owed nothing — answer zero + /// rather than reverting, so callers need no pre-check. + #[test] + fn test_get_game_fee_info_returns_zero_for_single_game_token() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + + let info = libs::get_game_fee_info(game); + assert!(info.fee_numerator == 0, "a game declaring no fee is owed nothing"); + } + + /// And the whole point of answering zero: the fee path stays total, so + /// `pay_game_fee` short-circuits before it ever needs a payee. + #[test] + fn test_zero_fee_means_nothing_to_pay_for_single_game_token() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + + let info = libs::get_game_fee_info(game); + assert!( + libs::calculate_game_fee(1_000_000, info.fee_numerator) == 0, + "no declared fee should compute to no payment", + ); + } + + /// An absent payee is NOT a benign zero — paying address 0 burns funds, so + /// asking for one that was never declared is a caller bug, not a state. + #[test] + #[should_panic(expected: "Metagame: game declares no fee recipient")] + fn test_get_game_fee_recipient_rejects_undeclared_payee() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + + libs::get_game_fee_recipient(game); + } + + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_assert_game_registered_rejects_mispaired_single_game_token() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + // The token is bound to a different game. + mock_call(token, selector!("game_address"), ALICE(), 1); + + libs::assert_game_registered(game); + } + + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_assert_game_registered_rejects_unbound_single_game_token() { + let zero_registry: ContractAddress = Zero::zero(); + let token = deploy_mock_token_with_registry(zero_registry); + let game = deploy_mock_minigame_for_registry(token); + // A token that names no game at all must not pass. + mock_call(token, selector!("game_address"), BOB(), 1); + + libs::assert_game_registered(game); + } +} + +// ============================================================================= +// HOSTILE GAME POINTING AT SOMEONE ELSE'S STANDARD TOKEN +// ============================================================================= +// +// A standard token is self-bound, so `token_address() == game_address` IS its +// registration check. Any path that trusts a game's `token_address()` must +// enforce it: otherwise a contract that merely implements `token_address()` +// can name a standard token it does not own and have the metagame mint on it +// or pay its fee recipient — at a fee rate the attacker controls. + +#[cfg(test)] +mod hostile_game_paths { + use game_components_testing::constants::{ALICE, BOB, OWNER}; + use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; + use starknet::ContractAddress; + use crate::metagame::metagame as libs; + + fn deploy_standard_game(game_fee_recipient: ContractAddress) -> ContractAddress { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + game_fee_recipient.serialize(ref calldata); + OWNER().serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + contract_address + } + + /// A hostile "game" that reports a victim's standard token as its own. + fn hostile_game_pointing_at(victim_token: ContractAddress) -> ContractAddress { + let hostile: ContractAddress = 0xBAD.try_into().unwrap(); + mock_call(hostile, selector!("token_address"), victim_token, 10); + hostile + } + + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_mint_rejects_game_pointing_at_foreign_standard_token() { + let victim = deploy_standard_game(ALICE()); + let hostile = hostile_game_pointing_at(victim); + + libs::mint( + hostile, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + BOB(), + false, + false, + 0, + 0, + ); + } + + /// Fee terms must not be readable through a game that does not own the token. + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_get_game_fee_info_rejects_foreign_standard_token() { + let victim = deploy_standard_game(ALICE()); + let hostile = hostile_game_pointing_at(victim); + libs::get_game_fee_info(hostile); + } + + /// The payee must not be redirectable to a foreign token's fee recipient. + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_get_game_fee_recipient_rejects_foreign_standard_token() { + let victim = deploy_standard_game(ALICE()); + let hostile = hostile_game_pointing_at(victim); + libs::get_game_fee_recipient(hostile); + } + + /// The self-bound game itself still works through all three paths. + #[test] + fn test_self_bound_game_still_passes_every_path() { + let game = deploy_standard_game(ALICE()); + libs::assert_game_registered(game); + assert!(libs::get_game_fee_recipient(game) == ALICE(), "payee should be the recipient"); + assert!(libs::get_game_fee_info(game).fee_numerator == 500, "default fee is 500 bps"); + } +} diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_metagame_component.cairo index 2c21af85..17c5579a 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_metagame_component.cairo @@ -1,16 +1,14 @@ -use core::num::traits::Zero; -use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use game_components_testing::constants::{ALICE, OWNER}; use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; use starknet::ContractAddress; use crate::metagame::extensions::context::structs::GameContextDetails; -use crate::metagame::interface::{IMETAGAME_ID, IMetagameDispatcher, IMetagameDispatcherTrait}; // Interface for testing mint function #[starknet::interface] trait IMockMetagame { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -24,139 +22,13 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } -// Test T001.1: Initialize with both token and context addresses -#[test] -fn test_initialization_with_both_addresses() { - let token_address: ContractAddress = 0x123.try_into().unwrap(); - let context_address: ContractAddress = 0x456.try_into().unwrap(); - - // Mock supports_interface for both addresses - mock_call(token_address, selector!("supports_interface"), true, 10); - mock_call(context_address, selector!("supports_interface"), true, 10); - - // Deploy the MockMetagameContract - let contract = declare("MockMetagameContract").unwrap().contract_class(); - // Serialize Option::Some(context_address) and minigame_token_address - let mut calldata = array![]; - // Option::Some variant (index 0 for Some) - calldata.append(0); - calldata.append(context_address.into()); - calldata.append(token_address.into()); - - let (contract_address, _) = contract.deploy(@calldata).unwrap(); - - let dispatcher = IMetagameDispatcher { contract_address }; - - // Verify addresses are stored correctly - assert!(dispatcher.default_token_address() == token_address, "Token address mismatch"); - assert!(dispatcher.context_address() == context_address, "Context address mismatch"); - - // Verify SRC5 interface registration - let src5_dispatcher = ISRC5Dispatcher { contract_address }; - assert!(src5_dispatcher.supports_interface(IMETAGAME_ID), "Should support IMetagame interface"); -} - -// Test T001.2: Initialize with token address only (context = None) -#[test] -fn test_initialization_with_token_only() { - let token_address: ContractAddress = 0x789.try_into().unwrap(); - - // Mock supports_interface for token address - mock_call(token_address, selector!("supports_interface"), true, 10); - - // Deploy with None for context_address - let contract = declare("MockMetagameContract").unwrap().contract_class(); - // Serialize Option::None and minigame_token_address - let mut calldata = array![]; - // Option::None variant (index 1 for None) - calldata.append(1); - calldata.append(token_address.into()); - - let (contract_address, _) = contract.deploy(@calldata).unwrap(); - - let dispatcher = IMetagameDispatcher { contract_address }; - - // Verify token address is stored and context is zero - assert!(dispatcher.default_token_address() == token_address, "Token address mismatch"); - assert!(dispatcher.context_address().is_zero(), "Context address should be zero"); - - // Verify SRC5 interface registration - let src5_dispatcher = ISRC5Dispatcher { contract_address }; - assert!(src5_dispatcher.supports_interface(IMETAGAME_ID), "Should support IMetagame interface"); -} - -// Test T002.1: minigame_token_address returns correct value after init -#[test] -fn test_minigame_token_address_view() { - let token_address: ContractAddress = 0xABC.try_into().unwrap(); - let context_address: ContractAddress = 0xDEF.try_into().unwrap(); - - // Mock supports_interface for both addresses - mock_call(token_address, selector!("supports_interface"), true, 10); - mock_call(context_address, selector!("supports_interface"), true, 10); - - // Deploy with both addresses - let contract = declare("MockMetagameContract").unwrap().contract_class(); - let mut calldata = array![]; - calldata.append(0); // Some(context_address) - calldata.append(context_address.into()); - calldata.append(token_address.into()); - - let (contract_address, _) = contract.deploy(@calldata).unwrap(); - let dispatcher = IMetagameDispatcher { contract_address }; - - // Verify minigame_token_address returns correct value - assert!(dispatcher.default_token_address() == token_address, "Token address mismatch"); -} - -// Test T002.2: context_address returns correct value when set -#[test] -fn test_context_address_view_when_set() { - let token_address: ContractAddress = 0x111.try_into().unwrap(); - let context_address: ContractAddress = 0x222.try_into().unwrap(); - - // Mock supports_interface for both addresses - mock_call(token_address, selector!("supports_interface"), true, 10); - mock_call(context_address, selector!("supports_interface"), true, 10); - - // Deploy with both addresses - let contract = declare("MockMetagameContract").unwrap().contract_class(); - let mut calldata = array![]; - calldata.append(0); // Some(context_address) - calldata.append(context_address.into()); - calldata.append(token_address.into()); - - let (contract_address, _) = contract.deploy(@calldata).unwrap(); - let dispatcher = IMetagameDispatcher { contract_address }; - - // Verify context_address returns correct value - assert!(dispatcher.context_address() == context_address, "Context address mismatch"); -} - -// Test T002.3: context_address returns zero when None passed -#[test] -fn test_context_address_view_when_none() { - let token_address: ContractAddress = 0x333.try_into().unwrap(); - - // Mock supports_interface for token address - mock_call(token_address, selector!("supports_interface"), true, 10); - - // Deploy with None for context_address - let contract = declare("MockMetagameContract").unwrap().contract_class(); - let mut calldata = array![]; - calldata.append(1); // None - calldata.append(token_address.into()); - - let (contract_address, _) = contract.deploy(@calldata).unwrap(); - let dispatcher = IMetagameDispatcher { contract_address }; - - // Verify context_address returns zero - assert!(dispatcher.context_address().is_zero(), "Context address should be zero"); -} +// The IMetagame ABI was removed with self-binding — there are no addresses to +// initialize or read back, so the former T001/T002 view tests have no subject. +// Coverage of what remains lives in the mint/fee tests below. // Test MG-U-04: Mint minimal (to address only) #[test] @@ -172,13 +44,16 @@ fn test_mint_minimal() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; // Mint with minimal parameters (only to address) let to_address = 0x1234.try_into().unwrap(); let token_id = dispatcher .mint( - Option::None, // game_address + game_address, Option::None, // player_name Option::None, // settings_id Option::None, // start @@ -215,6 +90,9 @@ fn test_mint_with_all_parameters() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; // Mint with all parameters (except context and game_address which require special setup) @@ -223,7 +101,7 @@ fn test_mint_with_all_parameters() { let token_id = dispatcher .mint( - Option::None, // Use default token path (game_address requires deployed game contract) + game_address, Option::Some('Player One'), Option::Some(1), // settings_id Option::Some(1000), // start @@ -264,6 +142,9 @@ fn test_mint_with_context_provider_set() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; use crate::metagame::extensions::context::structs::GameContext; let context = GameContextDetails { @@ -280,7 +161,7 @@ fn test_mint_with_context_provider_set() { let to_address = 0x5678.try_into().unwrap(); let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -316,6 +197,9 @@ fn test_mint_with_context_no_provider() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; // Mint with context - this should succeed because the token contract handles context @@ -329,7 +213,7 @@ fn test_mint_with_context_no_provider() { let to_address: ContractAddress = 0x1234.try_into().unwrap(); let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -364,12 +248,15 @@ fn test_mint_with_objective_id() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; let to_address = 0x1234.try_into().unwrap(); let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -403,6 +290,9 @@ fn test_mint_with_instant_game() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; // Mint with start = end (instant game) @@ -411,7 +301,7 @@ fn test_mint_with_instant_game() { let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::Some(timestamp), // start @@ -443,8 +333,6 @@ mod MockMetagameContract { component!(path: SRC5Component, storage: src5, event: SRC5Event); // Embed the implementations - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -473,16 +361,14 @@ mod MockMetagameContract { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -496,7 +382,7 @@ mod MockMetagameContract { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -595,10 +481,10 @@ mod MockContext { #[starknet::contract] mod MockMinigameToken { use core::num::traits::Zero; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameToken, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, }; - use game_components_embeddable_game_standard::token::structs::{ + use game_components_embeddable_game_standard::token_legacy::structs::{ Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; @@ -628,7 +514,7 @@ mod MockMinigameToken { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -950,7 +836,7 @@ mod MockMinigameToken { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } @@ -968,7 +854,7 @@ mod MockMinigameToken { // - Zero token address: "Metagame: Default token address is zero" // - Zero context address: "Metagame: Context address is zero" // - Token doesn't support interface: "Metagame: Default token contract does not support -// IMinigameToken" +// IMinigameTokenLegacy" // - Context doesn't support interface: "Metagame: Context contract does not support // IMetagameContext" @@ -988,6 +874,9 @@ fn test_mint_with_renderer_address() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; let renderer_address: ContractAddress = 0xBEEF.try_into().unwrap(); @@ -995,7 +884,7 @@ fn test_mint_with_renderer_address() { let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -1027,13 +916,16 @@ fn test_mint_with_settings_id() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; let to_address: ContractAddress = 0x1234.try_into().unwrap(); let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::Some(42), // settings_id Option::None, @@ -1065,13 +957,16 @@ fn test_mint_multiple_sequential() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameDispatcher { contract_address: metagame_address }; let to_address: ContractAddress = 0x1234.try_into().unwrap(); let token_id_1 = dispatcher .mint( - Option::None, + game_address, Option::Some('Player1'), Option::None, Option::None, @@ -1090,7 +985,7 @@ fn test_mint_multiple_sequential() { let token_id_2 = dispatcher .mint( - Option::None, + game_address, Option::Some('Player2'), Option::None, Option::None, @@ -1109,7 +1004,7 @@ fn test_mint_multiple_sequential() { let token_id_3 = dispatcher .mint( - Option::None, + game_address, Option::Some('Player3'), Option::None, Option::None, @@ -1143,13 +1038,16 @@ fn test_mint_batch_through_component() { calldata.append(token_address.into()); let (metagame_address, _) = metagame_contract.deploy(@calldata).unwrap(); + // Every mint now names its game; the game resolves to the mock token. + let game_address: ContractAddress = 0x6A3E.try_into().unwrap(); + mock_call(game_address, selector!("token_address"), token_address, 20); let dispatcher = IMockMetagameWithBatchDispatcher { contract_address: metagame_address }; let to_address: ContractAddress = 0x1234.try_into().unwrap(); let mints = array![ crate::metagame::structs::MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('BatchPlayer1'), settings_id: Option::None, start: Option::None, @@ -1166,7 +1064,7 @@ fn test_mint_batch_through_component() { metadata: 0, }, crate::metagame::structs::MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('BatchPlayer2'), settings_id: Option::None, start: Option::None, @@ -1205,8 +1103,6 @@ mod MockMetagameContractForErrors { component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); component!(path: SRC5Component, storage: src5, event: SRC5Event); - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -1235,9 +1131,7 @@ mod MockMetagameContractForErrors { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} } // Interface for batch testing @@ -1245,7 +1139,7 @@ mod MockMetagameContractForErrors { trait IMockMetagameWithBatch { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -1259,7 +1153,7 @@ trait IMockMetagameWithBatch { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; fn mint_batch( @@ -1278,8 +1172,6 @@ mod MockMetagameContractWithBatch { component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); component!(path: SRC5Component, storage: src5, event: SRC5Event); - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -1308,15 +1200,13 @@ mod MockMetagameContractWithBatch { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} #[abi(embed_v0)] impl MockMetagameWithBatchImpl of super::IMockMetagameWithBatch { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -1330,7 +1220,7 @@ mod MockMetagameContractWithBatch { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -1360,3 +1250,117 @@ mod MockMetagameContractWithBatch { } } } + +// ============================================================================= +// FEE TRANSFER +// ============================================================================= + +#[starknet::interface] +pub trait IMockFeePayer { + fn pay_game_fee( + ref self: TContractState, + game_address: ContractAddress, + payment_token: ContractAddress, + revenue: u128, + ) -> u128; +} + +#[starknet::contract] +pub mod MockFeePayer { + use openzeppelin_introspection::src5::SRC5Component; + use starknet::ContractAddress; + use crate::metagame::metagame_component::MetagameComponent; + + component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + impl MetagameInternalImpl = MetagameComponent::InternalImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + #[storage] + struct Storage { + #[substorage(v0)] + metagame: MetagameComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + MetagameEvent: MetagameComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[abi(embed_v0)] + impl MockFeePayerImpl of super::IMockFeePayer { + fn pay_game_fee( + ref self: ContractState, + game_address: ContractAddress, + payment_token: ContractAddress, + revenue: u128, + ) -> u128 { + self.metagame.pay_game_fee(game_address, payment_token, revenue) + } + } +} + +/// Deploys a standard game (game-fee surface, default 500 bps fee) plus the +/// fee-paying metagame. +fn deploy_fee_fixture() -> (ContractAddress, ContractAddress) { + let game = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + ALICE().serialize(ref calldata); // game fee recipient = payee + OWNER().serialize(ref calldata); + let (game_address, _) = game.deploy(@calldata).unwrap(); + + let payer = declare("MockFeePayer").unwrap().contract_class(); + let (payer_address, _) = payer.deploy(@array![]).unwrap(); + (game_address, payer_address) +} + +/// An ERC20 that returns false rather than reverting must not be reported as +/// a paid fee. +#[test] +#[should_panic(expected: "Metagame: game fee transfer failed")] +fn test_pay_game_fee_rejects_false_returning_erc20() { + let (game_address, payer_address) = deploy_fee_fixture(); + let payment_token: ContractAddress = 0xE20.try_into().unwrap(); + mock_call(payment_token, selector!("transfer"), false, 1); + + IMockFeePayerDispatcher { contract_address: payer_address } + .pay_game_fee(game_address, payment_token, 1_000_000); +} + +/// The happy path still returns the computed fee. +#[test] +fn test_pay_game_fee_returns_amount_on_success() { + let (game_address, payer_address) = deploy_fee_fixture(); + let payment_token: ContractAddress = 0xE20.try_into().unwrap(); + mock_call(payment_token, selector!("transfer"), true, 1); + + let paid = IMockFeePayerDispatcher { contract_address: payer_address } + .pay_game_fee(game_address, payment_token, 1_000_000); + // DEFAULT_GAME_FEE_BPS = 500 => 5% of 1_000_000 + assert!(paid == 50_000, "fee should be 5% of revenue, got {}", paid); +} + +/// Zero revenue short-circuits before any transfer. +#[test] +fn test_pay_game_fee_zero_revenue_pays_nothing() { + let (game_address, payer_address) = deploy_fee_fixture(); + let payment_token: ContractAddress = 0xE20.try_into().unwrap(); + + let paid = IMockFeePayerDispatcher { contract_address: payer_address } + .pay_game_fee(game_address, payment_token, 0); + assert!(paid == 0, "zero revenue should pay no fee"); +} diff --git a/packages/embeddable_game_standard/src/metagame/tests/test_tournament_flow.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_tournament_flow.cairo index 9877a4de..f66debbf 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_tournament_flow.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_tournament_flow.cairo @@ -1,5 +1,5 @@ -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; use snforge_std::{ ContractClassTrait, DeclareResultTrait, declare, start_cheat_caller_address, @@ -66,7 +66,7 @@ fn test_tournament_flow() { start_cheat_caller_address(metagame_address, player1); let p1_g1_token = metagame_dispatcher .mint( - Option::Some(game1_address), + game1_address, Option::Some('Player1'), Option::None, Option::Some(1000), @@ -85,7 +85,7 @@ fn test_tournament_flow() { let p1_g2_token = metagame_dispatcher .mint( - Option::Some(game2_address), + game2_address, Option::Some('Player1'), Option::None, Option::Some(1000), @@ -107,7 +107,7 @@ fn test_tournament_flow() { start_cheat_caller_address(metagame_address, player2); let p2_g1_token = metagame_dispatcher .mint( - Option::Some(game1_address), + game1_address, Option::Some('Player2'), Option::None, Option::Some(1000), @@ -126,7 +126,7 @@ fn test_tournament_flow() { let p2_g2_token = metagame_dispatcher .mint( - Option::Some(game2_address), + game2_address, Option::Some('Player2'), Option::None, Option::Some(1000), @@ -145,7 +145,7 @@ fn test_tournament_flow() { let p2_g3_token = metagame_dispatcher .mint( - Option::Some(game3_address), + game3_address, Option::Some('Player2'), Option::None, Option::Some(1000), @@ -165,7 +165,7 @@ fn test_tournament_flow() { // 9. Verify all tokens have context let context_dispatcher = IMetagameContextDispatcher { contract_address: context_address }; - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address }; // Store contexts in mock (in real scenario, this would be done during mint) let context_setter = IContextSetterDispatcher { contract_address: context_address }; @@ -228,8 +228,6 @@ mod MockMetagameWithContext { component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); component!(path: SRC5Component, storage: src5, event: SRC5Event); - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[storage] @@ -254,16 +252,14 @@ mod MockMetagameWithContext { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -277,7 +273,7 @@ mod MockMetagameWithContext { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -307,7 +303,7 @@ mod MockMetagameWithContext { trait IMockMetagame { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -321,7 +317,7 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -422,10 +418,10 @@ mod MockContextProvider { #[starknet::contract] mod MockTokenContract { use core::num::traits::Zero; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameToken, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, }; - use game_components_embeddable_game_standard::token::structs::{ + use game_components_embeddable_game_standard::token_legacy::structs::{ Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; @@ -454,7 +450,7 @@ mod MockTokenContract { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -772,7 +768,7 @@ mod MockTokenContract { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } diff --git a/packages/embeddable_game_standard/src/minigame/extensions/objectives/libs.cairo b/packages/embeddable_game_standard/src/minigame/extensions/objectives/libs.cairo index ffe27cb7..617d6e86 100644 --- a/packages/embeddable_game_standard/src/minigame/extensions/objectives/libs.cairo +++ b/packages/embeddable_game_standard/src/minigame/extensions/objectives/libs.cairo @@ -1,11 +1,19 @@ -use game_components_embeddable_game_standard::token::extensions::objectives::interface::{ - IMinigameTokenObjectivesDispatcher, IMinigameTokenObjectivesDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::extensions::objectives::interface::{ + IMINIGAME_TOKEN_OBJECTIVES_ID, IMinigameTokenObjectivesDispatcher, + IMinigameTokenObjectivesDispatcherTrait, }; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::minigame::extensions::objectives::structs::GameObjectiveDetails; -/// Creates an objective in the minigame token contract +/// Announces a created objective to the minigame token contract, when it has +/// an objectives surface. +/// +/// Same rationale as `settings::libs::create_settings`: the token-side call is +/// an indexer announcement, the game remains the source of truth, and +/// standard tokens (no objectives surface, no `IMINIGAME_TOKEN_OBJECTIVES_ID` +/// registration) skip the announcement instead of reverting. /// /// # Arguments /// * `minigame_token_address` - The address of the minigame token contract @@ -20,6 +28,10 @@ pub fn create_objective( objective_id: u32, objective_details: GameObjectiveDetails, ) { + let token_src5 = ISRC5Dispatcher { contract_address: minigame_token_address }; + if !token_src5.supports_interface(IMINIGAME_TOKEN_OBJECTIVES_ID) { + return; + } let minigame_token_dispatcher = IMinigameTokenObjectivesDispatcher { contract_address: minigame_token_address, }; diff --git a/packages/embeddable_game_standard/src/minigame/extensions/settings/libs.cairo b/packages/embeddable_game_standard/src/minigame/extensions/settings/libs.cairo index 645c73e4..6e2a49f7 100644 --- a/packages/embeddable_game_standard/src/minigame/extensions/settings/libs.cairo +++ b/packages/embeddable_game_standard/src/minigame/extensions/settings/libs.cairo @@ -1,9 +1,11 @@ -use game_components_embeddable_game_standard::token::extensions::settings::interface::{ - IMinigameTokenSettingsDispatcher, IMinigameTokenSettingsDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::extensions::settings::interface::{ + IMINIGAME_TOKEN_SETTINGS_ID, IMinigameTokenSettingsDispatcher, + IMinigameTokenSettingsDispatcherTrait, }; -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::minigame::extensions::settings::structs::GameSettingDetails; @@ -16,13 +18,23 @@ use crate::minigame::extensions::settings::structs::GameSettingDetails; /// # Returns /// * `u32` - The settings ID pub fn get_settings_id(minigame_token_address: ContractAddress, token_id: felt252) -> u32 { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher.settings_id(token_id) } -/// Creates settings in the minigame token contract +/// Announces created settings to the minigame token contract, when it has a +/// settings surface. +/// +/// The token-side `create_settings` stores nothing — it validates and emits a +/// `SettingsCreated` event for indexers. The game contract remains the source +/// of truth for what settings exist (`settings_exist` answers from the game). +/// Standard tokens have no settings surface at all and do not register +/// `IMINIGAME_TOKEN_SETTINGS_ID`, so the announcement is skipped for them +/// instead of reverting with ENTRYPOINT_NOT_FOUND — which would otherwise +/// brick settings creation (and constructors that create default settings) +/// for every game wired to a standard token. /// /// # Arguments /// * `minigame_token_address` - The address of the minigame token contract @@ -37,6 +49,10 @@ pub fn create_settings( settings_id: u32, settings_details: GameSettingDetails, ) { + let token_src5 = ISRC5Dispatcher { contract_address: minigame_token_address }; + if !token_src5.supports_interface(IMINIGAME_TOKEN_SETTINGS_ID) { + return; + } let minigame_token_dispatcher = IMinigameTokenSettingsDispatcher { contract_address: minigame_token_address, }; diff --git a/packages/embeddable_game_standard/src/minigame/minigame.cairo b/packages/embeddable_game_standard/src/minigame/minigame.cairo index efbe3b65..3949f453 100644 --- a/packages/embeddable_game_standard/src/minigame/minigame.cairo +++ b/packages/embeddable_game_standard/src/minigame/minigame.cairo @@ -3,8 +3,8 @@ use game_components_embeddable_game_standard::metagame::extensions::context::str use game_components_embeddable_game_standard::registry::interface::{ IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, }; -use game_components_embeddable_game_standard::token::interface::{ - IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +use game_components_embeddable_game_standard::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; use starknet::ContractAddress; @@ -25,7 +25,7 @@ pub fn pre_action(minigame_token_address: ContractAddress, token_id: felt252) { /// * `minigame_token_address` - The address of the minigame token contract /// * `token_id` - The game token ID to update pub fn post_action(minigame_token_address: ContractAddress, token_id: felt252) { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher.update_game(token_id); @@ -61,7 +61,7 @@ pub fn assert_token_ownership(minigame_token_address: ContractAddress, token_id: /// * `minigame_token_address` - The address of the minigame token contract /// * `token_id` - The token ID to check playability for pub fn assert_game_token_playable(minigame_token_address: ContractAddress, token_id: felt252) { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher.assert_is_playable(token_id); @@ -159,7 +159,7 @@ pub fn mint( salt: u16, metadata: u16, ) -> felt252 { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher @@ -196,7 +196,7 @@ pub fn mint_batch( game_address: ContractAddress, mints: Array, ) -> Array { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; @@ -249,7 +249,7 @@ pub fn mint_batch( /// # Returns /// * `felt252` - The player name pub fn get_player_name(minigame_token_address: ContractAddress, token_id: felt252) -> felt252 { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher.player_name(token_id) diff --git a/packages/embeddable_game_standard/src/minigame/minigame_component.cairo b/packages/embeddable_game_standard/src/minigame/minigame_component.cairo index b71c7c41..b87f124a 100644 --- a/packages/embeddable_game_standard/src/minigame/minigame_component.cairo +++ b/packages/embeddable_game_standard/src/minigame/minigame_component.cairo @@ -8,8 +8,9 @@ pub mod MinigameComponent { use game_components_embeddable_game_standard::registry::interface::{ IMINIGAME_REGISTRY_ID, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, }; - use game_components_embeddable_game_standard::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, + use game_components_embeddable_game_standard::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacyDispatcher, + IMinigameTokenLegacyDispatcherTrait, }; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use openzeppelin_introspection::src5::SRC5Component; @@ -129,9 +130,12 @@ pub mod MinigameComponent { let token_src5_dispatcher = ISRC5Dispatcher { contract_address: token_address }; let supports_minigame_token = token_src5_dispatcher - .supports_interface(IMINIGAME_TOKEN_ID); - assert!(supports_minigame_token, "Minigame: Token does not support IMINIGAME_TOKEN_ID"); - let minigame_token_dispatcher = IMinigameTokenDispatcher { + .supports_interface(IMINIGAME_TOKEN_LEGACY_ID); + assert!( + supports_minigame_token, + "Minigame: Token does not support IMINIGAME_TOKEN_LEGACY_ID", + ); + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: token_address, }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); diff --git a/packages/embeddable_game_standard/src/minigame/tests/test_minigame_component.cairo b/packages/embeddable_game_standard/src/minigame/tests/test_minigame_component.cairo index a2327df1..e5e6ae37 100644 --- a/packages/embeddable_game_standard/src/minigame/tests/test_minigame_component.cairo +++ b/packages/embeddable_game_standard/src/minigame/tests/test_minigame_component.cairo @@ -249,9 +249,9 @@ fn test_initialize_with_no_optional_addresses() { assert!(minigame_dispatcher.token_address() == token_address, "Token address mismatch"); } -// Test MN-U-03: Initialize with invalid token (missing IMINIGAME_TOKEN_ID) +// Test MN-U-03: Initialize with invalid token (missing IMINIGAME_TOKEN_LEGACY_ID) #[test] -#[should_panic(expected: "Minigame: Token does not support IMINIGAME_TOKEN_ID")] +#[should_panic(expected: "Minigame: Token does not support IMINIGAME_TOKEN_LEGACY_ID")] fn test_initialize_with_invalid_token() { let token_address = addr(0x123); diff --git a/packages/embeddable_game_standard/src/minigame/tests/test_objectives_libs.cairo b/packages/embeddable_game_standard/src/minigame/tests/test_objectives_libs.cairo index f89b5e59..701c944f 100644 --- a/packages/embeddable_game_standard/src/minigame/tests/test_objectives_libs.cairo +++ b/packages/embeddable_game_standard/src/minigame/tests/test_objectives_libs.cairo @@ -43,6 +43,8 @@ fn test_create_objective_valid_parameters() { let objective_id: u32 = 1; // Mock create_objective call + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'First Blood', value: 'Get the first kill' }]; @@ -64,6 +66,8 @@ fn test_create_objective_empty_name() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Objective 1', value: 'Some description' }]; @@ -83,6 +87,8 @@ fn test_create_objective_empty_description() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Empty Desc Objective', value: 'value' }]; @@ -102,6 +108,8 @@ fn test_create_objective_max_objective_id() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Max ID Objective', value: 'Testing boundary' }]; @@ -121,6 +129,8 @@ fn test_create_objective_zero_objective_id() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Zero ID Objective', value: 'Testing zero' }]; @@ -140,6 +150,8 @@ fn test_create_objective_long_name() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let long_name: ByteArray = @@ -162,6 +174,8 @@ fn test_create_objective_long_description() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let long_description: ByteArray = @@ -184,6 +198,8 @@ fn test_create_objective_special_chars_name() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![ @@ -205,6 +221,8 @@ fn test_create_objective_complex_content() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'complex_description', value: 'complex_value' }]; @@ -224,6 +242,8 @@ fn test_create_objective_multiple_in_span() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![ @@ -253,6 +273,8 @@ fn test_game_objective_details_struct_construction() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let name: ByteArray = "Complex Objective"; @@ -279,6 +301,7 @@ fn test_create_multiple_objectives_sequentially() { let creator_address = CREATOR(); // Mock multiple create_objective calls + mock_call(token_address, selector!("supports_interface"), true, 10); mock_call(token_address, selector!("create_objective"), (), 10); let obj1 = array![GameObjective { name: 'Obj 1', value: 'Desc 1' }]; @@ -318,6 +341,7 @@ fn test_create_objectives_different_games() { let game2: ContractAddress = 'GAME2'.try_into().unwrap(); let creator = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 10); mock_call(token_address, selector!("create_objective"), (), 10); let obj1 = array![GameObjective { name: 'Game 1 Objective', value: 'From game 1' }]; @@ -348,6 +372,7 @@ fn test_create_objectives_different_creators() { let creator1 = CREATOR(); let creator2 = ALICE(); + mock_call(token_address, selector!("supports_interface"), true, 10); mock_call(token_address, selector!("create_objective"), (), 10); let obj1 = array![GameObjective { name: 'Creator 1 Objective', value: 'From creator 1' }]; @@ -380,6 +405,8 @@ fn test_all_params_passed_through() { let name: ByteArray = "Test Objective"; let description: ByteArray = "Test Description"; + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Sub', value: 'Value' }]; @@ -403,6 +430,8 @@ fn test_create_objective_both_empty() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: '', value: '' }]; @@ -422,6 +451,8 @@ fn test_create_objective_very_long_strings() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let long_name: ByteArray = @@ -446,6 +477,7 @@ fn test_create_objectives_consecutive_ids() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 100); mock_call(token_address, selector!("create_objective"), (), 100); let objectives = array![GameObjective { name: 'Objective', value: 'Value' }]; @@ -473,6 +505,7 @@ fn test_create_objectives_boundary_ids() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 10); mock_call(token_address, selector!("create_objective"), (), 10); let objectives = array![GameObjective { name: 'Boundary', value: 'Value' }]; @@ -517,6 +550,8 @@ fn test_create_objective_empty_span() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives: Array = array![]; @@ -541,6 +576,8 @@ fn test_create_objective_fuzz_ids(objective_id: u32) { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: 'Fuzz Test', value: 'Fuzz Value' }]; @@ -561,6 +598,8 @@ fn test_create_objective_fuzz_content(seed: felt252) { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objective_id: u32 = (seed.try_into().unwrap_or(0_u128) % 1000000).try_into().unwrap(); @@ -586,6 +625,7 @@ fn test_create_objectives_sparse_ids() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 5); mock_call(token_address, selector!("create_objective"), (), 5); let objectives = array![GameObjective { name: 'Sparse', value: 'value' }]; @@ -627,6 +667,8 @@ fn test_create_objective_spaces_only() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_objective"), (), 1); let objectives = array![GameObjective { name: ' ', value: 'Normal value' }]; @@ -647,7 +689,9 @@ fn test_create_objective_different_tokens() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token1, selector!("supports_interface"), true, 1); mock_call(token1, selector!("create_objective"), (), 1); + mock_call(token2, selector!("supports_interface"), true, 1); mock_call(token2, selector!("create_objective"), (), 1); let obj1 = array![GameObjective { name: 'Token 1 Obj', value: 'v1' }]; diff --git a/packages/embeddable_game_standard/src/minigame/tests/test_settings_libs.cairo b/packages/embeddable_game_standard/src/minigame/tests/test_settings_libs.cairo index e8a22f3c..2101f9ee 100644 --- a/packages/embeddable_game_standard/src/minigame/tests/test_settings_libs.cairo +++ b/packages/embeddable_game_standard/src/minigame/tests/test_settings_libs.cairo @@ -116,6 +116,8 @@ fn test_create_settings_basic() { let settings_id: u32 = 1; // Mock create_settings call (returns unit) + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -136,6 +138,8 @@ fn test_create_settings_with_settings_data() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); let settings = array![GameSetting { name: 'speed', value: 'fast' }].span(); @@ -158,6 +162,8 @@ fn test_create_settings_multiple_settings() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); let settings = array![ @@ -182,6 +188,8 @@ fn test_create_settings_empty_name() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -203,6 +211,8 @@ fn test_create_settings_empty_description() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -224,6 +234,8 @@ fn test_create_settings_long_name() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); let long_name: ByteArray = @@ -247,6 +259,8 @@ fn test_create_settings_long_description() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); let long_desc: ByteArray = @@ -270,6 +284,8 @@ fn test_create_settings_max_id() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -290,6 +306,8 @@ fn test_create_settings_zero_id() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -310,6 +328,8 @@ fn test_create_settings_many_items() { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); // Create 20 settings items @@ -349,6 +369,8 @@ fn test_create_then_get_settings() { let settings_id: u32 = 10; // Mock create_settings call + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( @@ -379,6 +401,7 @@ fn test_multiple_games_settings() { let creator = CREATOR(); // Mock multiple create_settings calls + mock_call(token_address, selector!("supports_interface"), true, 10); mock_call(token_address, selector!("create_settings"), (), 10); // Game 1 creates settings @@ -450,6 +473,8 @@ fn test_create_settings_fuzz_id(settings_id: u32) { let game_address = GAME_ADDRESS(); let creator_address = CREATOR(); + mock_call(token_address, selector!("supports_interface"), true, 1); + mock_call(token_address, selector!("supports_interface"), true, 1); mock_call(token_address, selector!("create_settings"), (), 1); libs::create_settings( diff --git a/packages/embeddable_game_standard/src/token.cairo b/packages/embeddable_game_standard/src/token.cairo index d93159aa..77932585 100644 --- a/packages/embeddable_game_standard/src/token.cairo +++ b/packages/embeddable_game_standard/src/token.cairo @@ -1,10 +1,9 @@ -pub mod extensions; pub mod interface; -pub mod noop_traits; -pub mod structs; +pub mod minigame_token_component; +pub mod packing; +// The deployable merged game+token mock (StandardGameMock) lives in the +// test_common package so downstream consumers can declare it via +// build-external-contracts. #[cfg(test)] mod tests; -pub mod token; -pub mod token_component; -pub mod traits; diff --git a/packages/embeddable_game_standard/src/token/AGENTS.md b/packages/embeddable_game_standard/src/token/AGENTS.md index c63b9506..4d2efee1 100644 --- a/packages/embeddable_game_standard/src/token/AGENTS.md +++ b/packages/embeddable_game_standard/src/token/AGENTS.md @@ -1,92 +1,150 @@ -## Token Package - MinigameToken (ERC721) - -ERC721 NFT representing playable game instances. - -### Core Interface (IMinigameToken) - -**Interface ID:** `IMINIGAME_TOKEN_ID = 0x246f614bd76b91c378a91877851f2ccdb99278e9fb77c782a22355059ce9906` - -| Method | Signature | Description | -| ----------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------- | -| token_metadata | `(token_id: felt252) -> TokenMetadata` | Get full token metadata | -| is_playable | `(token_id: felt252) -> bool` | Check if token can be played | -| mint | `(...params) -> felt252` | Mint a single token | -| mint_batch_recipients | `(...shared params, recipients: Array, ...) -> Array` | Batch mint, per-recipient counts | -| update_game | `(token_id: felt252)` | Sync token state from game | - -**Batch views:** `*_batch` variants for all view functions (token_metadata, is_playable, settings_id, etc.) - -### Extension Interfaces - -| Extension | Interface ID | Key Methods | -| ---------- | ------------ | ------------------------------------------------------------- | -| Minter | `0x2198...` | get_minter_address(), get_minter_id(), minter_exists() | -| Objectives | `0x2c9b...` | create_objective() | -| Settings | `0x229b...` | create_settings() | -| Renderer | `0x2899...` | get_renderer(), has_custom_renderer(), reset_token_renderer() | -| Context | - | Game context attachment via GameContextDetails | - -### Storage Optimization - StorePacking - -TokenMetadata packed into single felt252 (219 bits): - -``` -| Bits 0-29 | game_id | 30 bits | -| Bits 30-64 | minted_at | 35 bits | -| Bits 65-96 | settings_id | 32 bits | -| Bits 97-166 | lifecycle | 70 bits | -| Bits 167-206| minted_by | 40 bits | -| Bits 207-210| flags | 4 bits | -| Bits 211-240| objective_id | 30 bits | -``` - -**Gas savings:** Reduces from ~6 storage slots to 1 slot per token. - -### PackedTokenId (Immutable in token_id) - -Token ID encodes immutable metadata (251 bits) eliminating storage reads: - -- game_id, minted_by, settings_id, minted_at -- lifecycle delays, objective_id, soulbound, has_context -- tx_hash (collision protection), salt (multicall protection) - -### Libs - -| File | Purpose | -| -------------------------- | ------------------------------------------- | -| `libs/lifecycle.cairo` | Lifecycle validation (start/end timestamps) | -| `libs/token_state.cairo` | Playability checks, state transitions | -| `libs/address_utils.cairo` | Address manipulation utilities | - -### Key Structs - -```cairo -struct TokenMetadata { - game_id: u64, minted_at: u64, settings_id: u32, - lifecycle: Lifecycle, minted_by: u64, soulbound: bool, - game_over: bool, completed_objective: bool, - has_context: bool, objective_id: u32 -} - -struct Lifecycle { start: u64, end: u64 } -struct MintBatchRecipient { to: ContractAddress, count: u16 } +# Token Module — MinigameTokenComponent (ERC721) + +THE minigame token standard: gas-optimized, single-game, self-bound. Built for +deployments that never used the multi-game +registry/objectives/context/skills/per-token renderer features, and keep +game-over / objective-completion authority in the game contract itself. The +original multi-game token lives on unchanged as the `token_legacy` module, +kept for deployed denshokan. + +**Self-binding only:** the component is embedded IN the game contract — the +game contract IS the token (one-address architecture). A separate-token +deployment shape existed briefly and was removed after measurements showed it +strictly worse on gas; keeping it alive meant dead machinery (`bind_game`, +two-phase init, a standalone preset, game-side call helpers). + +## Design Rules + +| Rule | Consequence | +| --- | --- | +| Self-bound: the embedding contract is the game | No stored game address, no registry, no `game_id` resolution, no SRC5 probes on mint; there is no game_address view or mint parameter at all — consumers identify a standard token by SRC5 (`IMINIGAME_TOKEN_ID`) | +| No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads. `player_name` (owner-renameable) and the mint-time `client_url` are the only per-token storage (plus the minter registry) | +| Token id layout is standard-native | `token::packing::pack_token_id` (251-bit) — its OWN layout, not the legacy token's (`token_legacy::structs` stays untouched, serving legacy denshokan). Indexers must branch their decoder by contract generation | +| Strip principle: machinery deleted, capability + read views kept | The ABI is NOT `IMinigameTokenLegacy`-compatible: the legacy token's `game_address`, `renderer_address` and `skills_address` mint params are gone, and the compat views (`game_address`, `game_registry_address`) with them. Cheap client-facing read views (`token_metadata`, `is_playable`, `settings_id`, `minted_by`, `is_soulbound`, …) stay | +| Restored mint params keep their original legacy-token behaviors | `objective_id` (30-bit packed, INERT data the game interprets — no completion machinery; `completed_objective` stays always-false), `context` (sets the has_context bit only; the data is NOT stored — legacy-token parity), `client_url` (storage-backed, `client_url` view, empty default), `paymaster` (packed bit), `metadata` (u128 param packed into a 65-bit field, read via `mint_metadata` — the shared `TokenMetadata.metadata: u16` cannot hold it and stays 0, never truncated) | +| The minter is standard, not optional | The minter registry is absorbed into `MinigameTokenComponent`: same storage variable names, same `IMinigameTokenMinter` surface (`MinterImpl`, `IMINIGAME_TOKEN_MINTER_ID`), same `MinterRegistryUpdate` event as the legacy `MinterComponent`. `OptionalMinter` indirection remains only in `token_legacy` | +| The game-fee surface is standard, not optional | The registry's `game_fee_info` role moves onto the token: `game_fee_recipient` (payout sink), license and fee (bps, default 500) are set in the initializer and served via `GameFeeImpl` (`IMinigameTokenGameFee`, `IMINIGAME_TOKEN_GAME_FEE_ID`). Setters are gated on the game contract's OZ Ownable OWNER (`assert_only_owner`, hard `OwnableComponent::HasComponent` bound) — the stored recipient is a payee, not an admin. Monetization platforms resolve the payee LIVE at claim time | +| Game contract is the authority | Games gate dead/finished runs themselves (internal `assert_owner_and_playable`) and call `refresh_metadata` (ERC-4906) after actions | + +## Token ID Layout (standard, 251 bits) + +Defined in `packing.cairo` (`pack_token_id` / `unpack_token_id` + +per-field helpers, DivRem-chain style shared with `token_legacy::structs` for +the u128_safe_divmod gas savings). No field crosses the u128 boundary. + +Low u128 (128 bits): + +| Bits | Field | Size | Notes | +| ------- | ----------- | ---- | --------------------------------------- | +| 0-34 | minted_at | 35 | unix seconds | +| 35-59 | start_delay | 25 | seconds after minted_at (~388 days max) | +| 60-84 | end_delay | 25 | 0 = no expiration (immortal) | +| 85-100 | settings_id | 16 | ABI stays `Option`; value must be ≤ 0xFFFF | +| 101-126 | minted_by | 26 | minter id from the absorbed `add_minter` (u64, must fit 26 bits) | +| 127 | soulbound | 1 | bool | + +High u128 (123 bits): + +| Bits | Field | Size | Notes | +| ------ | ------------ | ---- | -------------------------------------------- | +| 0-9 | tx_hash | 10 | last 10 bits of tx hash | +| 10-25 | salt | 16 | per-tx multicall counter (65,536 per tx) | +| 26 | paymaster | 1 | bool | +| 27 | has_context | 1 | bool; the context data itself is NOT stored | +| 28-57 | objective_id | 30 | inert data the game interprets | +| 58-122 | metadata | 65 | inert data the game interprets; u128 param, must be ≤ 2^65−1 | + +The high half is **fully allocated — there is no reserved region**: every +spare bit was merged into the single writable `metadata` field, in line with +the original layout's single-field design. A future protocol-owned field would +require a new contract generation (accepted trade-off). + +## Interface (IMinigameToken) + +**Interface ID:** `IMINIGAME_TOKEN_ID = 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea` +(derived over the trait minus `refresh_metadata`, mirroring the refresh +exclusion from `IMINIGAME_TOKEN_LEGACY_ID`) + +Defined in `packages/interfaces/src/token/core.cairo`. +`initializer(game_fee_recipient, license, fee_numerator)` stores the game-fee +terms (recipient must be non-zero; `license`/`fee_numerator` default to +`default_license()` / `DEFAULT_GAME_FEE_BPS` when None) and registers +`IMINIGAME_TOKEN_ID`, the absorbed minter's `IMINIGAME_TOKEN_MINTER_ID` and +the game-fee surface's `IMINIGAME_TOKEN_GAME_FEE_ID` — and nothing else: SRC5 +is honest, a standard token does not implement `IMinigameTokenLegacy` and +does not advertise the legacy id. Consumers branch on `IMINIGAME_TOKEN_ID` +instead of resolving registry/game-address views. + +| Method | Cost | Notes | +| --- | --- | --- | +| `mint(player_name, settings_id, start, end, objective_id, context, client_url, to, soulbound, paymaster, salt, metadata)` | 1 minter-map read (warm), optional name/url writes, ERC721 mint | 12-arg shape — no game address (self-bound), no renderer/skills. objective/paymaster/metadata pack into the id; context sets the has_context bit only; client_url written when Some | +| `mint_batch_recipients(player_name, settings_id, start, end, objective_id, context, client_url, recipients, soulbound, paymaster, salt, metadata)` | batch work hoisted; per token: pack + optional name/url writes + ERC721 mint | Global salt counter over the 16-bit field (`salt + sum(counts) - 1 <= 0xFFFF`); packed fields (incl. the has_context bit) shared across the batch, client_url written per token | +| `is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | +| `token_metadata`, `settings_id`, `minted_by`, `is_soulbound`, `objective_id`, `mint_metadata` | 0 storage reads | Pure unpack of the token id — kept as client/RPC conveniences (also derivable from the documented id layout). `token_metadata`'s u16 `metadata` field is always 0 (65 bits cannot fit; use `mint_metadata`) | +| `player_name`, `minted_by_address`, `client_url` | 1 storage read | | +| `refresh_metadata` | event only | Same advisory/no-existence-check semantics as the legacy token | +| `update_player_name` | owner-gated write | Emits `MetadataUpdate` | + +The absorbed minter registry additionally exposes the unchanged +`IMinigameTokenMinter` surface (`get_minter_address`, `get_minter_id`, +`minter_exists`, `total_minters`) via `MinigameTokenComponent::MinterImpl`. + +The game-fee surface (`MinigameTokenComponent::GameFeeImpl`, +`IMINIGAME_TOKEN_GAME_FEE_ID = 0x171bf98e08ae98315df3e68477e24275ef5755111c1984db851c344b3907bb0`) +exposes `game_fee_terms` / `game_fee_recipient` (reads) and +`set_game_fee_recipient` / `set_game_fee` (owner-gated writes; rotation to +zero rejected, fee capped at `FEE_DENOMINATOR`). Renamed from the creator +surface — function renames change extended selectors, so the retired +`IMINIGAME_TOKEN_CREATOR_ID` value is dead (no deployment registers it). + +Deleted from the ABI (strip principle — dead machinery and compat shims go, +capability and read views stay): + +* `game_address` / `game_registry_address` — compat shims; the pairing is + self == self and consumers probe `IMINIGAME_TOKEN_ID` via SRC5. +* `assert_is_playable` / `assert_owner_and_playable` — the embedding game's + own guards, `InternalTrait` calls now (zero syscalls); clients read + `is_playable`. +* `refresh_metadata_batch` — a multicall of singles. + +Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all batch +views, the objectives/settings/context creation and renderer/skills/enumerable +surfaces. + +## Composition + +**Preferred wiring: one embed.** `MinigameTokenComponent::MinigameTokenMixinImpl` +exposes the full standard surface (`MinigameTokenABI` = token + absorbed +minter + creator) in a single `#[abi(embed_v0)]` line — since the initializer +registers all three SRC5 ids unconditionally, the mixin keeps the advertised +ids honest by construction. The separate impls (`MinigameTokenImpl`, +`MinterImpl`, `GameFeeImpl`) remain exported; a contract wiring them +individually MUST embed all three or its SRC5 answers lie. + +Requires: `ERC721Component`, `SRC5Component`, `OwnableComponent` (hard +`HasComponent` bound on `GameFeeImpl` and the mixin — the owner administers +the game-fee surface), and an `ERC721HooksTrait` +(enforce soulbound in `before_update` via `token::packing::unpack_soulbound` +— pure, no storage; NOT the legacy token's `unpack_soulbound`, which reads a +different bit position). No separate minter component: the registry is +absorbed — embed `MinigameTokenComponent::MinterImpl` alongside +`MinigameTokenImpl`. The embedding contract is the game: it implements +`IMinigameTokenData` (score/game_over) itself and calls the component's +internal guard (`InternalTrait::assert_owner_and_playable`) and +`refresh_metadata` internally. + +See `test_common/src/mocks/standard_game_mock.cairo` (`StandardGameMock`) for +a full merged game+token wiring example — it lives in the test_common package +so downstream consumers can declare it in their own suites via +`build-external-contracts`. + +For metagames: `metagame::metagame::assert_game_registered` SRC5-probes the +game's token for `IMINIGAME_TOKEN_ID` first — a standard token means +"registered" is the self-binding equality `token_address == game_address`; +otherwise the legacy-token registry path runs unchanged. + +## Testing + +```bash +snforge test -p game_components_embeddable_game_standard "::token::" ``` - -### Extension Directory Structure - -``` -src/extensions/ - minter/ - Minting authorization - objectives/ - Objective tracking - settings/ - Game settings - renderer/ - Custom rendering - context/ - Game context -``` - -### Examples - -See `src/tests/examples/` for deployment patterns: - -- `minimal_optimized_example.cairo` - Minimal contract -- `full_token_contract.cairo` - All features enabled -- `single_game_token_contract.cairo` - Single game mode diff --git a/packages/embeddable_game_standard/src/token/interface.cairo b/packages/embeddable_game_standard/src/token/interface.cairo index 49f00d30..45d15f38 100644 --- a/packages/embeddable_game_standard/src/token/interface.cairo +++ b/packages/embeddable_game_standard/src/token/interface.cairo @@ -1,234 +1,4 @@ -// Re-export from interfaces package -pub use game_components_interfaces::registry::{ - GameMetadata, IMINIGAME_REGISTRY_ID, IMinigameRegistry, IMinigameRegistryDispatcher, - IMinigameRegistryDispatcherTrait, -}; -pub use game_components_interfaces::structs::metagame::GameContextDetails; -pub use game_components_interfaces::structs::minigame::{GameObjective, GameSetting}; -pub use game_components_interfaces::token::{ +// Re-export from interfaces package (single source of truth) +pub use game_components_interfaces::token::core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; -use starknet::ContractAddress; -use crate::token::structs::{ - MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, -}; - -#[starknet::interface] -pub trait IMinigameTokenMixin { - // Core token functionality - fn token_metadata(self: @TState, token_id: felt252) -> TokenMetadata; - fn is_playable(self: @TState, token_id: felt252) -> bool; - fn assert_is_playable(self: @TState, token_id: felt252); - fn settings_id(self: @TState, token_id: felt252) -> u32; - fn player_name(self: @TState, token_id: felt252) -> felt252; - fn objective_id(self: @TState, token_id: felt252) -> u32; - fn minted_by(self: @TState, token_id: felt252) -> felt252; - fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; - fn game_address(self: @TState) -> ContractAddress; - fn game_registry_address(self: @TState) -> ContractAddress; - fn is_soulbound(self: @TState, token_id: felt252) -> bool; - fn renderer_address(self: @TState, token_id: felt252) -> ContractAddress; - fn token_game_address(self: @TState, token_id: felt252) -> ContractAddress; - fn token_mutable_state(self: @TState, token_id: felt252) -> TokenMutableState; - fn client_url(self: @TState, token_id: felt252) -> ByteArray; - fn skills_address(self: @TState, token_id: felt252) -> ContractAddress; - - // Batch view functions - fn token_metadata_batch(self: @TState, token_ids: Span) -> Array; - fn is_playable_batch(self: @TState, token_ids: Span) -> Array; - fn settings_id_batch(self: @TState, token_ids: Span) -> Array; - fn player_name_batch(self: @TState, token_ids: Span) -> Array; - fn objective_id_batch(self: @TState, token_ids: Span) -> Array; - fn minted_by_batch(self: @TState, token_ids: Span) -> Array; - fn minted_by_address_batch(self: @TState, token_ids: Span) -> Array; - fn is_soulbound_batch(self: @TState, token_ids: Span) -> Array; - fn renderer_address_batch(self: @TState, token_ids: Span) -> Array; - fn token_game_address_batch(self: @TState, token_ids: Span) -> Array; - fn token_mutable_state_batch( - self: @TState, token_ids: Span, - ) -> Array; - fn token_full_state_batch(self: @TState, token_ids: Span) -> Array; - - fn mint( - ref self: TState, - game_address: ContractAddress, - player_name: Option, - settings_id: Option, - start: Option, - end: Option, - objective_id: Option, - context: Option, - client_url: Option, - renderer_address: Option, - skills_address: Option, - to: ContractAddress, - soulbound: bool, - paymaster: bool, - salt: u16, - metadata: u16, - ) -> felt252; - fn update_game(ref self: TState, token_id: felt252); - fn refresh_metadata(ref self: TState, token_id: felt252); - fn update_player_name(ref self: TState, token_id: felt252, name: felt252); - - // Batch write functions - fn mint_batch_recipients( - ref self: TState, - game_address: ContractAddress, - player_name: Option, - settings_id: Option, - start: Option, - end: Option, - objective_id: Option, - context: Option, - client_url: Option, - renderer_address: Option, - skills_address: Option, - recipients: Array, - soulbound: bool, - paymaster: bool, - salt: u16, - metadata: u16, - ) -> Array; - fn update_game_batch(ref self: TState, token_ids: Span); - fn refresh_metadata_batch(ref self: TState, token_ids: Span); - fn update_player_name_batch(ref self: TState, updates: Span); - - // Minter functionality - fn get_minter_address(self: @TState, minter_id: u64) -> starknet::ContractAddress; - fn get_minter_id(self: @TState, minter_address: starknet::ContractAddress) -> u64; - fn minter_exists(self: @TState, minter_address: starknet::ContractAddress) -> bool; - fn total_minters(self: @TState) -> u64; - // Objective functionality - fn create_objective( - ref self: TState, - game_address: ContractAddress, - creator_address: ContractAddress, - objective_id: u32, - settings_id: u32, - objective_data: GameObjective, - ); - // Settings functionality - fn create_settings( - ref self: TState, - game_address: ContractAddress, - settings_id: u32, - name: ByteArray, - description: ByteArray, - settings_data: Span, - ); - // Renderer functionality - fn get_renderer(self: @TState, token_id: felt252) -> starknet::ContractAddress; - fn has_custom_renderer(self: @TState, token_id: felt252) -> bool; - fn reset_token_renderer(ref self: TState, token_id: felt252); - - // Renderer batch operations - fn reset_token_renderer_batch(ref self: TState, token_ids: Span); - fn get_renderer_batch( - self: @TState, token_ids: Span, - ) -> Array; - - // Skills functionality - fn get_skills_address(self: @TState, token_id: felt252) -> starknet::ContractAddress; - fn has_custom_skills(self: @TState, token_id: felt252) -> bool; - fn reset_token_skills(ref self: TState, token_id: felt252); - - // Skills batch operations - fn reset_token_skills_batch(ref self: TState, token_ids: Span); - fn get_skills_address_batch( - self: @TState, token_ids: Span, - ) -> Array; -} - -// ============================================================================== -// TOKEN EVENT RELAYER - DEPRECATED -// ============================================================================== -// This interface is deprecated. Use native Starknet events instead. -// Keeping for backwards compatibility during migration. -// Will be removed in a future version. - -#[starknet::interface] -pub trait ITokenEventRelayer { - fn initialize( - ref self: TContractState, - token_address: ContractAddress, - game_registry_address: ContractAddress, - ); - - // Core token events - fn emit_owners( - ref self: TContractState, token_id: u64, owner: ContractAddress, auth: ContractAddress, - ); - fn emit_token_metadata_update( - ref self: TContractState, - id: u64, - game_id: u64, - minted_at: u64, - settings_id: u32, - lifecycle_start: u64, - lifecycle_end: u64, - minted_by: u64, - soulbound: bool, - game_over: bool, - completed_objective: bool, - has_context: bool, - objectives_count: u8, - ); - fn emit_token_player_name_update(ref self: TContractState, id: u64, player_name: felt252); - fn emit_token_client_url_update(ref self: TContractState, id: u64, client_url: ByteArray); - fn emit_token_score_update(ref self: TContractState, id: u64, score: u64); - - // Objectives extension events - fn emit_objective_created( - ref self: TContractState, - game_address: ContractAddress, - creator_address: ContractAddress, - objective_id: u32, - objective_data: ByteArray, - ); - fn emit_objective_update( - ref self: TContractState, token_id: u64, objective_id: u32, completed: bool, - ); - - // Settings extension events - fn emit_settings_created( - ref self: TContractState, - game_address: ContractAddress, - creator_address: ContractAddress, - settings_id: u32, - settings_data: ByteArray, - ); - - // Minter extension events - fn emit_minter_registry_update( - ref self: TContractState, id: u64, minter_address: ContractAddress, - ); - - // Context extension events - fn emit_token_context_update(ref self: TContractState, id: u64, context_data: ByteArray); - - // Additional renderer events - fn emit_token_renderer_update( - ref self: TContractState, id: u64, renderer_address: ContractAddress, - ); - - // MinigameRegistry events - fn emit_game_metadata_update( - ref self: TContractState, - id: u64, - contract_address: ContractAddress, - name: ByteArray, - description: ByteArray, - developer: ByteArray, - publisher: ByteArray, - genre: ByteArray, - image: ByteArray, - color: ByteArray, - client_url: ByteArray, - renderer_address: ContractAddress, - skills_address: ContractAddress, - ); - fn emit_game_registry_update( - ref self: TContractState, id: u64, contract_address: ContractAddress, - ); -} diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo new file mode 100644 index 00000000..7334287d --- /dev/null +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -0,0 +1,803 @@ +/// # MinigameTokenComponent +/// +/// THE minigame token standard: single-game, storage-minimal ERC721 embedded +/// in the game contract, built for deployments (e.g. death-mountain-style +/// dungeons) that never used the multi-game registry, objective-completion +/// machinery, skills or per-token renderers, and that keep game-over / +/// objective completion authority in the game contract itself. The original +/// multi-game token (`token_legacy::CoreTokenComponent`) is kept as-is for +/// deployed denshokan. +/// +/// **Self-binding only (one-address architecture):** this component is +/// embedded IN the game contract — the game contract IS the token. A +/// separate-token deployment shape existed briefly and was removed after +/// measurements showed it strictly worse on gas; with self-binding the +/// game/token mutual-pairing story is trivial (self == self), advertised to +/// ecosystem consumers via SRC5 (`IMINIGAME_TOKEN_ID`) rather than +/// through address-resolution views. +/// +/// The external ABI is `IMinigameToken`: dead MACHINERY and compat shims +/// are deleted, CAPABILITY (writes) and cheap client-facing read views stay. +/// What is gone, and why: +/// * **Registry / game-address views** — one game: this contract. Consumers +/// SRC5-probe `IMINIGAME_TOKEN_ID`; there is nothing to resolve. +/// * **Guards (`assert_is_playable`, `assert_owner_and_playable`)** — the +/// embedding game's own pre-action checks, now `InternalTrait` calls with +/// zero syscalls. Clients read `is_playable`. +/// * **`refresh_metadata_batch`** — a multicall of singles. +/// * **Mutable token state** — no `game_over`/`completed_objective` latch, +/// no `update_game`, no metagame callbacks. `refresh_metadata` (ERC-4906) +/// is the only post-action hook; `player_name` (owner-renameable via +/// `update_player_name`) and the mint-time `client_url` are the only +/// per-token storage. +/// +/// Mint parameters carry their original legacy-token behaviors: `objective_id`, +/// `paymaster` and the (65-bit, u128) `metadata` are packed into the id as +/// inert data the game interprets; `context` sets the id's has_context bit +/// only (the data is NOT stored — legacy-token parity); `client_url` is +/// storage-backed with a `client_url` view. +/// +/// The minter registry is standard, not optional: absorbed into this +/// component (storage names, `IMinigameTokenMinter` surface and +/// `MinterRegistryUpdate` event identical to the legacy MinterComponent's). +/// +/// Token ids use the standard's 251-bit layout in `token::packing` — NOT the +/// legacy token's `token_legacy::structs::pack_token_id` layout (which stays +/// untouched, serving legacy denshokan). Indexers must branch their token-id +/// decoder by contract generation. +#[starknet::component] +pub mod MinigameTokenComponent { + use core::num::traits::Zero; + use game_components_interfaces::structs::metagame::GameContextDetails; + use game_components_interfaces::token::core::{ + IMINIGAME_TOKEN_ID, IMinigameToken, MinigameTokenABI, + }; + use game_components_interfaces::token::game_fee::{ + DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameFeeTerms, IMINIGAME_TOKEN_GAME_FEE_ID, + IMinigameTokenGameFee, default_license, + }; + use game_components_interfaces::token::minter::{ + IMINIGAME_TOKEN_MINTER_ID, IMinigameTokenMinter, + }; + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_access::ownable::OwnableComponent::InternalTrait as OwnableInternalTrait; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; + use openzeppelin_token::erc721::ERC721Component; + use openzeppelin_token::erc721::ERC721Component::InternalTrait as ERC721InternalTrait; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; + use crate::token::packing::{ + extract_tx_hash_bits, pack_token_id, to_token_metadata, unpack_metadata, unpack_minted_by, + unpack_objective_id, unpack_settings_id, unpack_soulbound, unpack_token_id, + }; + use crate::token_legacy::structs::{MintBatchRecipient, TokenMetadata}; + use crate::token_legacy::token::{LifecycleTrait, token_state}; + + #[storage] + pub struct Storage { + token_player_names: Map, + token_client_url: Map, + // Absorbed minter registry. The variable names are EXACTLY those of the + // legacy MinterComponent — Starknet storage addresses derive from these + // names, so contracts that embedded MinterComponent keep their minter + // storage compatible under the absorbed impl. + minter_counter: u64, + minter_addresses: Map, + minter_id_by_address: Map, + // Game fee recipient + monetization terms. Replaces the retired + // registry's game_fee_info lookup: with no registry, the payee and + // fee live on the game contract itself, set at initialization and + // administered (rotation, fee changes) by the contract's OZ owner. + game_fee_recipient: ContractAddress, + game_fee_license: ByteArray, + game_fee_numerator: u16, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + MetadataUpdate: MetadataUpdate, + MinterRegistryUpdate: MinterRegistryUpdate, + GameFeeRecipientUpdate: GameFeeRecipientUpdate, + GameFeeUpdate: GameFeeUpdate, + } + + /// ERC-4906 standard metadata update event + #[derive(Drop, starknet::Event)] + pub struct MetadataUpdate { + #[key] + pub token_id: u256, + } + + /// Emitted when a new minter is registered (absorbed from the legacy + /// MinterComponent — same shape). + #[derive(Drop, starknet::Event)] + pub struct MinterRegistryUpdate { + #[key] + pub minter_id: u64, + pub minter_address: ContractAddress, + } + + /// Emitted when the game fee recipient is set or rotated. + #[derive(Drop, starknet::Event)] + pub struct GameFeeRecipientUpdate { + #[key] + pub recipient: ContractAddress, + } + + /// Emitted when the license / fee terms change. + #[derive(Drop, starknet::Event)] + pub struct GameFeeUpdate { + pub license: ByteArray, + pub fee_numerator: u16, + } + + #[embeddable_as(MinigameTokenImpl)] + pub impl MinigameToken< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + +Drop, + +ERC721Component::ERC721HooksTrait, + > of IMinigameToken> { + fn token_metadata( + self: @ComponentState, token_id: felt252, + ) -> TokenMetadata { + // No mutable state exists; the game contract is authoritative for + // game_over / objective completion — the returned metadata reports + // game_over/completed_objective/completed_at as false/0 always. + // Its u16 `metadata` field is 0 (never a truncation): the token id + // packs 65 bits — read them via `mint_metadata`. + to_token_metadata(unpack_token_id(token_id)) + } + + fn is_playable(self: @ComponentState, token_id: felt252) -> bool { + let metadata = self.token_metadata(token_id); + metadata.lifecycle.is_playable(get_block_timestamp()) + } + + fn settings_id(self: @ComponentState, token_id: felt252) -> u32 { + unpack_settings_id(token_id) + } + + fn player_name(self: @ComponentState, token_id: felt252) -> felt252 { + self.token_player_names.entry(token_id).read() + } + + fn minted_by(self: @ComponentState, token_id: felt252) -> felt252 { + let minted_by_val: u64 = unpack_minted_by(token_id); + minted_by_val.into() + } + + fn minted_by_address( + self: @ComponentState, token_id: felt252, + ) -> ContractAddress { + let minted_by_id: u64 = unpack_minted_by(token_id); + self.minter_addresses.entry(minted_by_id).read() + } + + fn is_soulbound(self: @ComponentState, token_id: felt252) -> bool { + unpack_soulbound(token_id) + } + + fn objective_id(self: @ComponentState, token_id: felt252) -> u32 { + unpack_objective_id(token_id) + } + + fn client_url(self: @ComponentState, token_id: felt252) -> ByteArray { + self.token_client_url.entry(token_id).read() + } + + fn mint_metadata(self: @ComponentState, token_id: felt252) -> u128 { + unpack_metadata(token_id) + } + + fn mint( + ref self: ComponentState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> felt252 { + let caller = get_caller_address(); + let current_time = get_block_timestamp(); + + // Same lifecycle rules as CoreTokenComponent::mint_game: a + // non-zero end must be in the future and after start (end_delay 0 + // means "no expiration", so a past window must not collapse into + // an immortal token), and a start at or before now clamps to now + // so the packed delays reconstruct the caller's intended end. + let lifecycle = token_state::create_lifecycle_with_defaults(start, end); + lifecycle.validate(); + assert!( + lifecycle.end == 0 + || (lifecycle.end > current_time && lifecycle.end > lifecycle.start), + "MinigameToken: Lifecycle end must be in the future and after start", + ); + let effective_start = if lifecycle.start > current_time { + lifecycle.start + } else { + current_time + }; + let start_delay: u32 = (effective_start - current_time).try_into().unwrap(); + let end_delay: u32 = if lifecycle.end > effective_start { + (lifecycle.end - effective_start).try_into().unwrap() + } else { + 0 + }; + + let tx_hash_bits = extract_tx_hash_bits(get_tx_info().unbox().transaction_hash); + + let minted_by = self.add_minter(caller); + + // settings_id keeps its Option call-site type; the pack + // asserts the value fits the id layout's 16-bit field. Likewise + // minted_by (u64 from add_minter) must fit 26 + // bits, objective_id 30 bits and metadata 65 bits. context sets + // the has_context bit only — the data itself is NOT stored + // (legacy-token parity: its context hook was a documented no-op). + let final_token_id = pack_token_id( + current_time, + start_delay, + end_delay, + settings_id.unwrap_or(0), + minted_by, + soulbound, + tx_hash_bits, + salt, + paymaster, + context.is_some(), + objective_id.unwrap_or(0), + metadata, + ); + + if let Option::Some(name) = player_name { + self.token_player_names.entry(final_token_id).write(name); + } + if let Option::Some(url) = client_url { + self.token_client_url.entry(final_token_id).write(url); + } + + let mut contract = self.get_contract_mut(); + let mut erc721_component = ERC721::get_component_mut(ref contract); + erc721_component.mint(to, final_token_id.into()); + + final_token_id + } + + /// Batch mint identical tokens to one or more recipients with + /// per-recipient counts. + /// + /// Salt is a single global counter across the batch (`salt + i` for + /// `i in 0..sum(counts)`): token ids do not encode the recipient, so + /// salts must be globally unique within the tx — + /// `salt + sum(counts) - 1 <= 0xFFFF` (the id layout's 16-bit field). + /// + /// Versus calling `mint` per token, the lifecycle math, tx-info read + /// and minter registration are hoisted and paid once for the batch. + fn mint_batch_recipients( + ref self: ComponentState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> Array { + let recipient_count = recipients.len(); + assert!(recipient_count > 0, "MinigameToken: recipients array cannot be empty"); + + // Sum per-recipient counts and bound the global salt counter. + let mut total_tokens: u32 = 0; + let mut sum_idx: u32 = 0; + while sum_idx < recipient_count { + let r: @MintBatchRecipient = recipients.at(sum_idx); + let c: u16 = *r.count; + assert!(c > 0, "MinigameToken: per-recipient count must be > 0"); + total_tokens += c.into(); + sum_idx += 1; + } + let max_salt: u32 = salt.into() + total_tokens - 1; + assert!( + max_salt <= 0xFFFF, + "MinigameToken: salt overflow (salt + total tokens - 1 must be <= 65535)", + ); + + // Hoisted per-batch work: lifecycle math (same rules and rationale as + // `mint`), tx-hash bits, minter registration. + let caller = get_caller_address(); + let current_time = get_block_timestamp(); + + let lifecycle = token_state::create_lifecycle_with_defaults(start, end); + lifecycle.validate(); + assert!( + lifecycle.end == 0 + || (lifecycle.end > current_time && lifecycle.end > lifecycle.start), + "MinigameToken: Lifecycle end must be in the future and after start", + ); + let effective_start = if lifecycle.start > current_time { + lifecycle.start + } else { + current_time + }; + let start_delay: u32 = (effective_start - current_time).try_into().unwrap(); + let end_delay: u32 = if lifecycle.end > effective_start { + (lifecycle.end - effective_start).try_into().unwrap() + } else { + 0 + }; + + let tx_hash_bits = extract_tx_hash_bits(get_tx_info().unbox().transaction_hash); + + let minted_by = self.add_minter(caller); + let validated_settings_id = settings_id.unwrap_or(0); + let validated_objective_id = objective_id.unwrap_or(0); + // Shared has_context bit for all minted tokens; the context data + // itself is NOT stored (legacy-token parity). + let has_context = context.is_some(); + + // Per-token work: pack, optional name/url writes, ERC721 mint. + let mut token_ids: Array = ArrayTrait::new(); + let mut salt_offset: u16 = 0; + let mut r_idx: u32 = 0; + while r_idx < recipient_count { + let r: @MintBatchRecipient = recipients.at(r_idx); + let to: ContractAddress = *r.to; + let count: u16 = *r.count; + + let mut k: u16 = 0; + while k < count { + let final_token_id = pack_token_id( + current_time, + start_delay, + end_delay, + validated_settings_id, + minted_by, + soulbound, + tx_hash_bits, + salt + salt_offset, + paymaster, + has_context, + validated_objective_id, + metadata, + ); + + if let Option::Some(name) = player_name { + self.token_player_names.entry(final_token_id).write(name); + } + match @client_url { + Option::Some(url) => { + self.token_client_url.entry(final_token_id).write(url.clone()); + }, + Option::None => {}, + } + + let mut contract = self.get_contract_mut(); + let mut erc721_component = ERC721::get_component_mut(ref contract); + erc721_component.mint(to, final_token_id.into()); + + token_ids.append(final_token_id); + salt_offset += 1; + k += 1; + } + r_idx += 1; + } + + token_ids + } + + /// Emits an ERC-4906 `MetadataUpdate` without touching state. Same + /// deliberate no-existence-check trade-off as + /// `CoreTokenComponent::refresh_metadata`: the event is advisory, + /// consumers resolve token ids against their own mint records, and + /// the check would cost ~52k gas on the cheap path without stopping + /// spam anyway. + fn refresh_metadata(ref self: ComponentState, token_id: felt252) { + self.emit(MetadataUpdate { token_id: token_id.into() }); + } + + fn update_player_name( + ref self: ComponentState, token_id: felt252, name: felt252, + ) { + assert!(!name.is_zero(), "MinigameToken: Player name is empty"); + let contract = self.get_contract(); + let erc721_component = ERC721::get_component(contract); + let token_owner = erc721_component._owner_of(token_id.into()); + assert!( + token_owner == get_caller_address(), "MinigameToken: Caller is not owner of token", + ); + self.token_player_names.entry(token_id).write(name); + self.emit(MetadataUpdate { token_id: token_id.into() }); + } + } + + /// The minter registry is standard, not optional: absorbed from the legacy + /// MinterComponent (same `IMinigameTokenMinter` interface and + /// `IMINIGAME_TOKEN_MINTER_ID`, same storage variable names, same + /// `MinterRegistryUpdate` event). Minter ids gate reward claims in + /// consumers; `OptionalMinter` indirection remains only in `token_legacy`. + #[embeddable_as(MinterImpl)] + pub impl Minter< + TContractState, +HasComponent, +Drop, + > of IMinigameTokenMinter> { + fn get_minter_address( + self: @ComponentState, minter_id: u64, + ) -> ContractAddress { + self.minter_addresses.entry(minter_id).read() + } + + fn get_minter_id( + self: @ComponentState, minter_address: ContractAddress, + ) -> u64 { + self.minter_id_by_address.entry(minter_address).read() + } + + fn minter_exists( + self: @ComponentState, minter_address: ContractAddress, + ) -> bool { + self.minter_id_by_address.entry(minter_address).read() != 0 + } + + fn total_minters(self: @ComponentState) -> u64 { + self.minter_counter.read() + } + } + + /// The game-fee surface is standard, not optional: with the registry retired, + /// this surface is the only place a monetization platform can resolve a + /// game's payee and minimum fee. The stored recipient is a payout sink; the + /// game contract's OZ OWNER administers it — both setters are gated with + /// `assert_only_owner`, and the `OwnableComponent::HasComponent` bound + /// makes that a compile-time requirement: every contract embedding this + /// impl MUST also embed `OwnableComponent`. + #[embeddable_as(GameFeeImpl)] + pub impl GameFee< + TContractState, + +HasComponent, + impl Own: OwnableComponent::HasComponent, + +Drop, + > of IMinigameTokenGameFee> { + fn game_fee_terms(self: @ComponentState) -> GameFeeTerms { + GameFeeTerms { + recipient: self.game_fee_recipient.read(), + license: self.game_fee_license.read(), + fee_numerator: self.game_fee_numerator.read(), + } + } + + fn game_fee_recipient(self: @ComponentState) -> ContractAddress { + self.game_fee_recipient.read() + } + + fn set_game_fee_recipient( + ref self: ComponentState, new_recipient: ContractAddress, + ) { + Own::get_component(self.get_contract()).assert_only_owner(); + // Rotation must never brick the payee. + assert!(!new_recipient.is_zero(), "MinigameToken: Fee recipient cannot be zero"); + self.game_fee_recipient.write(new_recipient); + self.emit(GameFeeRecipientUpdate { recipient: new_recipient }); + } + + fn set_game_fee( + ref self: ComponentState, license: ByteArray, fee_numerator: u16, + ) { + Own::get_component(self.get_contract()).assert_only_owner(); + assert!( + fee_numerator <= FEE_DENOMINATOR, + "MinigameToken: Fee numerator exceeds denominator", + ); + self.game_fee_license.write(license.clone()); + self.game_fee_numerator.write(fee_numerator); + self.emit(GameFeeUpdate { license, fee_numerator }); + } + } + + /// One-embed mixin over the full standard surface (token + absorbed + /// minter + game fee), mirroring OZ's ERC20MixinImpl pattern. The + /// initializer registers all three SRC5 ids unconditionally, so embedding + /// this single impl — rather than MinigameTokenImpl / MinterImpl / + /// GameFeeImpl separately — makes it impossible for the advertised ids to + /// diverge from the exposed entrypoints (honest SRC5 by construction). + /// The separate impls remain exported for contracts that wire them + /// individually. + #[embeddable_as(MinigameTokenMixinImpl)] + pub impl MinigameTokenMixin< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + impl Own: OwnableComponent::HasComponent, + +Drop, + +ERC721Component::ERC721HooksTrait, + > of MinigameTokenABI> { + // IMinigameToken + fn token_metadata( + self: @ComponentState, token_id: felt252, + ) -> TokenMetadata { + MinigameToken::token_metadata(self, token_id) + } + fn is_playable(self: @ComponentState, token_id: felt252) -> bool { + MinigameToken::is_playable(self, token_id) + } + fn settings_id(self: @ComponentState, token_id: felt252) -> u32 { + MinigameToken::settings_id(self, token_id) + } + fn player_name(self: @ComponentState, token_id: felt252) -> felt252 { + MinigameToken::player_name(self, token_id) + } + fn minted_by(self: @ComponentState, token_id: felt252) -> felt252 { + MinigameToken::minted_by(self, token_id) + } + fn minted_by_address( + self: @ComponentState, token_id: felt252, + ) -> ContractAddress { + MinigameToken::minted_by_address(self, token_id) + } + fn is_soulbound(self: @ComponentState, token_id: felt252) -> bool { + MinigameToken::is_soulbound(self, token_id) + } + fn objective_id(self: @ComponentState, token_id: felt252) -> u32 { + MinigameToken::objective_id(self, token_id) + } + fn client_url(self: @ComponentState, token_id: felt252) -> ByteArray { + MinigameToken::client_url(self, token_id) + } + fn mint_metadata(self: @ComponentState, token_id: felt252) -> u128 { + MinigameToken::mint_metadata(self, token_id) + } + fn mint( + ref self: ComponentState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> felt252 { + MinigameToken::mint( + ref self, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + to, + soulbound, + paymaster, + salt, + metadata, + ) + } + fn mint_batch_recipients( + ref self: ComponentState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> Array { + MinigameToken::mint_batch_recipients( + ref self, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + recipients, + soulbound, + paymaster, + salt, + metadata, + ) + } + fn refresh_metadata(ref self: ComponentState, token_id: felt252) { + MinigameToken::refresh_metadata(ref self, token_id) + } + fn update_player_name( + ref self: ComponentState, token_id: felt252, name: felt252, + ) { + MinigameToken::update_player_name(ref self, token_id, name) + } + + // IMinigameTokenMinter + fn get_minter_address( + self: @ComponentState, minter_id: u64, + ) -> ContractAddress { + Minter::get_minter_address(self, minter_id) + } + fn get_minter_id( + self: @ComponentState, minter_address: ContractAddress, + ) -> u64 { + Minter::get_minter_id(self, minter_address) + } + fn minter_exists( + self: @ComponentState, minter_address: ContractAddress, + ) -> bool { + Minter::minter_exists(self, minter_address) + } + fn total_minters(self: @ComponentState) -> u64 { + Minter::total_minters(self) + } + + // IMinigameTokenGameFee + fn game_fee_terms(self: @ComponentState) -> GameFeeTerms { + GameFee::game_fee_terms(self) + } + fn game_fee_recipient(self: @ComponentState) -> ContractAddress { + GameFee::game_fee_recipient(self) + } + fn set_game_fee_recipient( + ref self: ComponentState, new_recipient: ContractAddress, + ) { + GameFee::set_game_fee_recipient(ref self, new_recipient) + } + fn set_game_fee( + ref self: ComponentState, license: ByteArray, fee_numerator: u16, + ) { + GameFee::set_game_fee(ref self, license, fee_numerator) + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + +Drop, + +ERC721Component::ERC721HooksTrait, + > of InternalTrait { + /// Returns the caller's minter id, registering the caller (and + /// emitting `MinterRegistryUpdate`) on first sight — identical + /// semantics to the legacy MinterComponent's `add_minter`. + fn add_minter(ref self: ComponentState, minter: ContractAddress) -> u64 { + // Existing minter short-circuits with its id + let existing_id = self.minter_id_by_address.entry(minter).read(); + if existing_id != 0 { + return existing_id; + } + + // Register new minter + let minter_id = self.minter_counter.read() + 1; + self.minter_addresses.entry(minter_id).write(minter); + self.minter_id_by_address.entry(minter).write(minter_id); + self.minter_counter.write(minter_id); + + self.emit(MinterRegistryUpdate { minter_id, minter_address: minter }); + + minter_id + } + + /// Stores the game fee recipient + terms and registers the SRC5 ids: + /// `IMINIGAME_TOKEN_ID`, the absorbed minter's + /// `IMINIGAME_TOKEN_MINTER_ID` and the game-fee surface's + /// `IMINIGAME_TOKEN_GAME_FEE_ID`. There is no game argument — the + /// component is self-bound: the embedding contract is the game. The + /// legacy id is NOT registered; SRC5 is honest about the surface + /// (this token does NOT implement `IMinigameTokenLegacy`). + /// + /// INVARIANT the embedder must uphold: all three ids are registered + /// UNCONDITIONALLY, so the contract must expose all three surfaces — + /// embed `MinigameTokenMixinImpl` (one line, guaranteed), or embed + /// `MinigameTokenImpl` + `MinterImpl` + `GameFeeImpl` all together. + /// A partial wiring that still calls this initializer advertises + /// entrypoints it does not have, and probe-then-dispatch consumers + /// (e.g. metagame's fee resolution) will revert against it. Note the + /// trigger point: that revert fires at first FEE CLAIM, not at + /// deploy — an integration test that exercises fee payment is what + /// catches a partial wiring before production does. + /// + /// `game_fee_recipient` must be non-zero (it is the monetization payee); + /// `license`/`fee_numerator` default to the ecosystem terms + /// (`default_license()`, `DEFAULT_GAME_FEE_BPS` = 500 bps) when None — + /// matching what the retired registry granted games that declared + /// nothing. + fn initializer( + ref self: ComponentState, + game_fee_recipient: ContractAddress, + license: Option, + fee_numerator: Option, + ) { + assert!(!game_fee_recipient.is_zero(), "MinigameToken: Fee recipient cannot be zero"); + let fee = fee_numerator.unwrap_or(DEFAULT_GAME_FEE_BPS); + assert!(fee <= FEE_DENOMINATOR, "MinigameToken: Fee numerator exceeds denominator"); + self.game_fee_recipient.write(game_fee_recipient); + let license_value = match license { + Option::Some(l) => l, + Option::None => default_license(), + }; + self.game_fee_license.write(license_value); + self.game_fee_numerator.write(fee); + self.emit(GameFeeRecipientUpdate { recipient: game_fee_recipient }); + + let mut contract = self.get_contract_mut(); + let mut src5_component = SRC5::get_component_mut(ref contract); + src5_component.register_interface(IMINIGAME_TOKEN_ID); + // The absorbed minter registry keeps its own discovery id + // (matching what the legacy MinterComponent::initializer did). + src5_component.register_interface(IMINIGAME_TOKEN_MINTER_ID); + src5_component.register_interface(IMINIGAME_TOKEN_GAME_FEE_ID); + } + + /// Combined ownership + playability guard for the embedding game's + /// own entrypoints: internal call, zero syscalls. `expected_owner` is + /// the game contract's caller (must be non-zero); panics unless it + /// owns the token and the lifecycle window is open. + fn assert_owner_and_playable( + self: @ComponentState, + token_id: felt252, + expected_owner: ContractAddress, + ) { + assert!(!expected_owner.is_zero(), "MinigameToken: Expected owner cannot be zero"); + let contract = self.get_contract(); + let erc721_component = ERC721::get_component(contract); + // _owner_of returns zero for a nonexistent token, which can never + // equal the asserted-non-zero expected_owner — so this also + // guarantees existence. + let token_owner = erc721_component._owner_of(token_id.into()); + assert!( + token_owner == expected_owner, + "MinigameToken: Address is not owner of token {}", + token_id, + ); + self.assert_lifecycle_open(token_id); + } + + /// Lifecycle-window check only — there is deliberately no token-side + /// game_over / completed_objective state to consult. Games gate dead + /// runs themselves; they are the source of truth. + fn assert_lifecycle_open(self: @ComponentState, token_id: felt252) { + let metadata = to_token_metadata(unpack_token_id(token_id)); + let current_time = get_block_timestamp(); + let lifecycle = metadata.lifecycle; + assert!( + lifecycle.can_start(current_time), + "MinigameToken: Token is not playable - game has not started (now={}, start={})", + current_time, + lifecycle.start, + ); + assert!( + !lifecycle.has_expired(current_time), + "MinigameToken: Token is not playable - game has expired (now={}, end={})", + current_time, + lifecycle.end, + ); + } + } +} diff --git a/packages/embeddable_game_standard/src/token/packing.cairo b/packages/embeddable_game_standard/src/token/packing.cairo new file mode 100644 index 00000000..c2b1d883 --- /dev/null +++ b/packages/embeddable_game_standard/src/token/packing.cairo @@ -0,0 +1,412 @@ +// ============================================================================== +// PACKED TOKEN ID - Embeds immutable data directly in the token_id (felt252) +// ============================================================================== +// +// Standard u128-aligned bit layout (251 bits, no field straddles the u128 +// boundary). This layout is OWNED by the standard token and is deliberately NOT the +// legacy token's `token_legacy::structs::pack_token_id` layout — the legacy layout serves +// legacy denshokan and keeps its bit positions untouched; the standard token drops +// the fields it never writes (game_id) and widens the ones it uses beyond the +// legacy token's widths (settings_id 16, salt 16, metadata 65). +// Indexers must branch their decoder by contract generation. +// +// Low u128 (128 bits): +// | Bits | Field | Size | Max Value | +// |-----------|------------------|----------|--------------------------------| +// | 0-34 | minted_at | 35 bits | Unix timestamp (~1000 years) | +// | 35-59 | start_delay | 25 bits | 33,554,431 seconds (~388 days) | +// | 60-84 | end_delay | 25 bits | 33,554,431 seconds (~388 days) | +// | 85-100 | settings_id | 16 bits | 65,535 settings | +// | 101-126 | minted_by | 26 bits | 67,108,863 minters | +// | 127 | soulbound | 1 bit | bool | +// +// High u128 (123 bits): +// | Bits | Field | Size | Max Value | +// |-----------|------------------|----------|--------------------------------| +// | 0-9 | tx_hash | 10 bits | last 10 bits of tx hash | +// | 10-25 | salt | 16 bits | 65,536 tokens per tx (multicall)| +// | 26 | paymaster | 1 bit | bool | +// | 27 | has_context | 1 bit | bool | +// | 28-57 | objective_id | 30 bits | 1,073,741,823 objectives | +// | 58-122 | metadata | 65 bits | game-interpreted inert data | +// Total: 128 + 123 = 251 bits (max for felt252) +// +// Max value: (2^123 - 1) * 2^128 + (2^128 - 1) = 2^251 - 1 < P (Stark prime) +// +// The high half is fully allocated — there is no reserved region: every spare +// bit was merged into the single writable `metadata` field, in line with the +// original layout's single-field design. A future protocol-owned field would +// require a new contract generation (accepted trade-off). +// +// COLLISION PROTECTION: +// - tx_hash: Last 10 bits of starknet transaction hash. Since tx_hash includes +// the sender's nonce (unique per tx), different transactions have different +// hashes. This protects against same-block collisions. +// - salt: Client-provided value for multicall scenarios. Client must increment +// salt for each mint within the same transaction to avoid collisions. +// +// CODEC (shared with SDM next-death-mountain's model packing — same method): +// - PACK is pure felt252 arithmetic: a valid token id occupies at most 251 +// bits, so every term and partial sum is below the Stark prime and native +// felt add/mul is exact — no u128 multiplications, no u256 assembly. +// - UNPACK splits each u128 half ONCE at a field-aligned boundary so that the +// resulting words fit u64, then extracts every field with cheap u64 DivRem: +// * low splits at bit 60 (start_delay|end_delay boundary): the bottom word +// (minted_at + start_delay) fits u64; one more u128 DivRem at end_delay +// brings the 43-bit top (settings_id + minted_by + soulbound) into u64. +// * high splits at bit 58: metadata (65 bits) is the quotient and stays +// u128 (it is returned as u128 anyway); the 58-bit remainder word +// (tx_hash + salt + paymaster + has_context + objective_id) fits u64. +// Full unpack: 3 u128 + 6 u64 DivRems (was 10 u128 DivRems). + +use game_components_interfaces::structs::token::{Lifecycle, TokenMetadata}; +// Shared with the legacy token: extracting the last 10 bits of the tx hash is +// layout-independent. +pub use crate::token_legacy::structs::extract_tx_hash_bits; + +/// Data structure representing the packed token ID fields (for convenience). +#[derive(Copy, Drop, Serde)] +pub struct PackedTokenId { + pub minted_at: u64, // 35 bits + pub start_delay: u32, // 25 bits + pub end_delay: u32, // 25 bits + pub settings_id: u32, // 16 bits + pub minted_by: u64, // 26 bits + pub soulbound: bool, // 1 bit + pub tx_hash: u16, // 10 bits - last 10 bits of transaction hash for collision protection + pub salt: u16, // 16 bits - client-provided salt for multicall collision protection + pub paymaster: bool, // 1 bit + pub has_context: bool, // 1 bit - context data itself is NOT stored (legacy-token parity) + pub objective_id: u32, // 30 bits - inert data the game interprets + pub metadata: u128 // 65 bits - inert data the game interprets +} + +/// NonZero constants — used only for the per-half word splits and the +/// helpers that shift a field to the bottom of a u128 in one DivRem. +mod nz128 { + pub const TWO_POW_10: NonZero = 0x400; + pub const TWO_POW_25: NonZero = 0x2000000; + pub const TWO_POW_35: NonZero = 0x800000000; + pub const TWO_POW_58: NonZero = 0x400000000000000; + pub const TWO_POW_60: NonZero = 0x1000000000000000; + pub const TWO_POW_85: NonZero = 0x2000000000000000000000; + pub const TWO_POW_101: NonZero = 0x20000000000000000000000000; + pub const TWO_POW_127: NonZero = 0x80000000000000000000000000000000; +} + +/// NonZero constants — every field extraction after the word splits runs +/// on u64 operands (u64 DivRem is markedly cheaper than u128 DivRem). +mod nz64 { + pub const TWO_POW_1: NonZero = 0x2; + pub const TWO_POW_10: NonZero = 0x400; + pub const TWO_POW_16: NonZero = 0x10000; + pub const TWO_POW_26: NonZero = 0x4000000; + pub const TWO_POW_27: NonZero = 0x8000000; + pub const TWO_POW_28: NonZero = 0x10000000; + pub const TWO_POW_35: NonZero = 0x800000000; +} + +/// felt252 shift constants for the pure-felt pack. Low-half fields shift by +/// their bit offset; high-half fields shift by their offset WITHIN the high +/// word and the assembled high word shifts by SHIFT_128 at the end. +mod felt_shift { + // Low half offsets + pub const SHIFT_35: felt252 = 0x800000000; // start_delay + pub const SHIFT_60: felt252 = 0x1000000000000000; // end_delay + pub const SHIFT_85: felt252 = 0x2000000000000000000000; // settings_id + pub const SHIFT_101: felt252 = 0x20000000000000000000000000; // minted_by + pub const SHIFT_127: felt252 = 0x80000000000000000000000000000000; // soulbound + // High half offsets (within the high word) + pub const SHIFT_10: felt252 = 0x400; // salt + pub const SHIFT_26: felt252 = 0x4000000; // paymaster + pub const SHIFT_27: felt252 = 0x8000000; // has_context + pub const SHIFT_28: felt252 = 0x10000000; // objective_id + pub const SHIFT_58: felt252 = 0x400000000000000; // metadata + // Low/high boundary + pub const SHIFT_128: felt252 = 0x100000000000000000000000000000000; +} + +/// Packs token metadata into a felt252 token_id using the standard +/// u128-aligned layout. This is a pure function - no storage access needed. +/// +/// Low u128: minted_at(35) | start_delay(25) | end_delay(25) | settings_id(16) +/// | minted_by(26) | soulbound(1) = 128 bits +/// High u128: tx_hash(10) | salt(16) | paymaster(1) | has_context(1) +/// | objective_id(30) | metadata(65) = 123 bits (fully allocated) +#[inline(always)] +pub fn pack_token_id( + minted_at: u64, + start_delay: u32, + end_delay: u32, + settings_id: u32, + minted_by: u64, + soulbound: bool, + tx_hash: u16, + salt: u16, + paymaster: bool, + has_context: bool, + objective_id: u32, + metadata: u128, +) -> felt252 { + // Validate all fields fit within their bit allocations + assert!(minted_at <= 0x7FFFFFFFF, "PackedTokenId: minted_at exceeds 35-bit limit"); + assert!(start_delay <= 0x1FFFFFF, "PackedTokenId: start_delay exceeds 25-bit limit"); + assert!(end_delay <= 0x1FFFFFF, "PackedTokenId: end_delay exceeds 25-bit limit"); + assert!(settings_id <= 0xFFFF, "PackedTokenId: settings_id exceeds 16-bit limit"); + assert!(minted_by <= 0x3FFFFFF, "PackedTokenId: minted_by exceeds 26-bit limit"); + assert!(objective_id <= 0x3FFFFFFF, "PackedTokenId: objective_id exceeds 30-bit limit"); + assert!(metadata <= 0x1FFFFFFFFFFFFFFFF, "PackedTokenId: metadata exceeds 65-bit limit"); + + // Pure felt252 packing (SDM method): the asserts above bound every field, + // so the total occupies at most 251 bits and every term and partial sum + // is below the Stark field prime — native felt arithmetic is exact. + let soulbound_f: felt252 = if soulbound { + 1 + } else { + 0 + }; + let paymaster_f: felt252 = if paymaster { + 1 + } else { + 0 + }; + let has_context_f: felt252 = if has_context { + 1 + } else { + 0 + }; + + // Low u128: minted_at(35) + start_delay(25) + end_delay(25) + settings_id(16) + // + minted_by(26) + soulbound(1) = 128 bits + let low: felt252 = minted_at.into() + + start_delay.into() * felt_shift::SHIFT_35 + + end_delay.into() * felt_shift::SHIFT_60 + + settings_id.into() * felt_shift::SHIFT_85 + + minted_by.into() * felt_shift::SHIFT_101 + + soulbound_f * felt_shift::SHIFT_127; + + // High u128: tx_hash(10) + salt(16) + paymaster(1) + has_context(1) + // + objective_id(30) + metadata(65) = 123 bits — fully + // allocated, no reserved region. salt is a u16 written into a + // 16-bit field, so unlike the legacy token's 10-bit salt it + // needs no mask. + let high: felt252 = Into::::into(tx_hash & 0x3FF) + + salt.into() * felt_shift::SHIFT_10 + + paymaster_f * felt_shift::SHIFT_26 + + has_context_f * felt_shift::SHIFT_27 + + objective_id.into() * felt_shift::SHIFT_28 + + metadata.into() * felt_shift::SHIFT_58; + + low + high * felt_shift::SHIFT_128 +} + +/// Unpacks a token_id into its component fields (SDM word-split method): +/// each u128 half is split once at a field-aligned boundary so the resulting +/// words fit u64, and every field extraction runs as a cheap u64 DivRem. +#[inline(always)] +pub fn unpack_token_id(token_id: felt252) -> PackedTokenId { + let packed: u256 = token_id.into(); + + // Low half — split at bit 60 (the start_delay|end_delay boundary): the + // bottom word (minted_at + start_delay, 60 bits) fits u64; one more u128 + // DivRem peels end_delay and leaves a 43-bit top word + // (settings_id + minted_by + soulbound) that also fits u64. + let (low_rest, low_word) = DivRem::div_rem(packed.low, nz128::TWO_POW_60); + let low_word: u64 = low_word.try_into().unwrap(); + let (start_delay, minted_at) = DivRem::div_rem(low_word, nz64::TWO_POW_35); + let (low_top, end_delay) = DivRem::div_rem(low_rest, nz128::TWO_POW_25); + let low_top: u64 = low_top.try_into().unwrap(); + let (rest, settings_id) = DivRem::div_rem(low_top, nz64::TWO_POW_16); + let (soulbound_u64, minted_by) = DivRem::div_rem(rest, nz64::TWO_POW_26); + + // High half — split at bit 58: metadata (65 bits) is the quotient and + // stays u128; the 58-bit remainder word + // (tx_hash + salt + paymaster + has_context + objective_id) fits u64. + let (metadata, high_word) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + let high_word: u64 = high_word.try_into().unwrap(); + let (rest, tx_hash) = DivRem::div_rem(high_word, nz64::TWO_POW_10); + let (rest, salt) = DivRem::div_rem(rest, nz64::TWO_POW_16); + let (rest, paymaster_u64) = DivRem::div_rem(rest, nz64::TWO_POW_1); + let (objective_id, has_context_u64) = DivRem::div_rem(rest, nz64::TWO_POW_1); + + PackedTokenId { + minted_at, + start_delay: start_delay.try_into().unwrap(), + end_delay: end_delay.try_into().unwrap(), + settings_id: settings_id.try_into().unwrap(), + minted_by, + soulbound: soulbound_u64 == 1, + tx_hash: tx_hash.try_into().unwrap(), + salt: salt.try_into().unwrap(), + paymaster: paymaster_u64 == 1, + has_context: has_context_u64 == 1, + objective_id: objective_id.try_into().unwrap(), + metadata, + } +} + +/// Helper to unpack just minted_at from a token_id +#[inline(always)] +pub fn unpack_minted_at(token_id: felt252) -> u64 { + let packed: u256 = token_id.into(); + let (_, minted_at) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + minted_at.try_into().unwrap() +} + +/// Helper to unpack just start_delay from a token_id +#[inline(always)] +pub fn unpack_start_delay(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + // Bottom word (60 bits) fits u64; start_delay is its quotient at bit 35. + let (_, low_word) = DivRem::div_rem(packed.low, nz128::TWO_POW_60); + let low_word: u64 = low_word.try_into().unwrap(); + let (start_delay, _) = DivRem::div_rem(low_word, nz64::TWO_POW_35); + start_delay.try_into().unwrap() +} + +/// Helper to unpack just end_delay from a token_id +#[inline(always)] +pub fn unpack_end_delay(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + // end_delay sits at bits 60-84: shift to bottom, then take 25 bits. + let (rest, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_60); + let (_, end_delay) = DivRem::div_rem(rest, nz128::TWO_POW_25); + end_delay.try_into().unwrap() +} + +/// Helper to unpack just settings_id from a token_id +#[inline(always)] +pub fn unpack_settings_id(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + // Everything above bit 85 is 43 bits — fits u64; settings_id is its + // bottom 16 bits. + let (top, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_85); + let top: u64 = top.try_into().unwrap(); + let (_, settings_id) = DivRem::div_rem(top, nz64::TWO_POW_16); + settings_id.try_into().unwrap() +} + +/// Helper to unpack just minted_by from a token_id +#[inline(always)] +pub fn unpack_minted_by(token_id: felt252) -> u64 { + let packed: u256 = token_id.into(); + // Everything above bit 101 is 27 bits — fits u64; minted_by is its + // bottom 26 bits. + let (top, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_101); + let top: u64 = top.try_into().unwrap(); + let (_, minted_by) = DivRem::div_rem(top, nz64::TWO_POW_26); + minted_by +} + +/// Helper to unpack the soulbound flag from a token_id +#[inline(always)] +pub fn unpack_soulbound(token_id: felt252) -> bool { + let packed: u256 = token_id.into(); + // soulbound is the top bit of the low half — a single quotient. + let (soulbound_u128, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_127); + soulbound_u128 == 1 +} + +/// Helper to unpack tx_hash from a token_id (last 10 bits of transaction hash) +#[inline(always)] +pub fn unpack_tx_hash(token_id: felt252) -> u16 { + let packed: u256 = token_id.into(); + let (_, tx_hash) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + tx_hash.try_into().unwrap() +} + +/// Helper to unpack salt from a token_id (client-provided collision protection) +#[inline(always)] +pub fn unpack_salt(token_id: felt252) -> u16 { + let packed: u256 = token_id.into(); + // The 58-bit high word fits u64; salt sits above tx_hash's 10 bits. + let (_, high_word) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + let high_word: u64 = high_word.try_into().unwrap(); + let (rest, _) = DivRem::div_rem(high_word, nz64::TWO_POW_10); + let (_, salt) = DivRem::div_rem(rest, nz64::TWO_POW_16); + salt.try_into().unwrap() +} + +/// Helper to unpack the paymaster flag from a token_id +#[inline(always)] +pub fn unpack_paymaster(token_id: felt252) -> bool { + let packed: u256 = token_id.into(); + let (_, high_word) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + let high_word: u64 = high_word.try_into().unwrap(); + let (rest, _) = DivRem::div_rem(high_word, nz64::TWO_POW_26); + let (_, paymaster_u64) = DivRem::div_rem(rest, nz64::TWO_POW_1); + paymaster_u64 == 1 +} + +/// Helper to unpack the has_context flag from a token_id. The context +/// data itself is NOT stored on the token (legacy-token parity) — only this bit +/// records that context was supplied at mint. +#[inline(always)] +pub fn unpack_has_context(token_id: felt252) -> bool { + let packed: u256 = token_id.into(); + let (_, high_word) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + let high_word: u64 = high_word.try_into().unwrap(); + let (rest, _) = DivRem::div_rem(high_word, nz64::TWO_POW_27); + let (_, has_context_u64) = DivRem::div_rem(rest, nz64::TWO_POW_1); + has_context_u64 == 1 +} + +/// Helper to unpack objective_id from a token_id (inert data the game +/// interprets — the standard token has no completion machinery) +#[inline(always)] +pub fn unpack_objective_id(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + // objective_id is the top field of the 58-bit high word — a quotient. + let (_, high_word) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + let high_word: u64 = high_word.try_into().unwrap(); + let (objective_id, _) = DivRem::div_rem(high_word, nz64::TWO_POW_28); + objective_id.try_into().unwrap() +} + +/// Helper to unpack the 65-bit metadata field from a token_id (inert +/// data the game interprets). Topmost high field — a single quotient. +#[inline(always)] +pub fn unpack_metadata(token_id: felt252) -> u128 { + let packed: u256 = token_id.into(); + let (metadata, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); + metadata +} + +/// Convert PackedTokenId to the shared TokenMetadata struct. +/// +/// The standard token has no mutable state and never resolves a game id, so +/// `game_id`, `game_over`, `completed_objective` and `completed_at` are all +/// zeroed (the game contract is authoritative — `completed_objective` stays +/// always-false even when an objective_id is packed). The lifecycle is +/// reconstructed from minted_at + delays with the same rule as the legacy +/// token: end_delay == 0 means "no expiration" (end == 0). +/// +/// `metadata` is 0 here, NOT a truncation of the packed value: the shared +/// struct's `metadata` field is `u16` (the deployed legacy token's ABI, which +/// cannot change), while the id packs 65 bits. Read the real value via +/// `IMinigameToken::mint_metadata` / `unpack_metadata`. +#[inline(always)] +pub fn to_token_metadata(packed: PackedTokenId) -> TokenMetadata { + TokenMetadata { + game_id: 0, + minted_at: packed.minted_at, + settings_id: packed.settings_id, + lifecycle: Lifecycle { + start: packed.minted_at + packed.start_delay.into(), + end: if packed.end_delay > 0 { + packed.minted_at + packed.start_delay.into() + packed.end_delay.into() + } else { + 0 + }, + }, + minted_by: packed.minted_by, + soulbound: packed.soulbound, + game_over: false, + completed_objective: false, + completed_at: 0, + has_context: packed.has_context, + objective_id: packed.objective_id, + paymaster: packed.paymaster, + metadata: 0, + } +} diff --git a/packages/embeddable_game_standard/src/token/tests.cairo b/packages/embeddable_game_standard/src/token/tests.cairo index 1ec0be60..bf8ed428 100644 --- a/packages/embeddable_game_standard/src/token/tests.cairo +++ b/packages/embeddable_game_standard/src/token/tests.cairo @@ -1,33 +1,7 @@ -// Token package tests -// Migrated from tests/src/token/ following cairo-contracts test structure +// Token module tests +// +// The deployable merged game+token contract (StandardGameMock) is declared from +// game_components_test_common::mocks via build-external-contracts. -mod examples; -mod libs; -mod mocks; -mod setup; -mod test_additional_coverage; -mod test_address_utils; -mod test_batch_views; -mod test_component_coverage; -mod test_context; -mod test_context_coverage; -mod test_core_token; -mod test_core_token_coverage; -mod test_enumerable; -mod test_events; -mod test_examples_coverage; -mod test_extensions; -mod test_full_token_contract; -mod test_fuzz; -mod test_integration; -mod test_lifecycle; -mod test_minimal_optimized; -mod test_minter; -mod test_noop_traits; -mod test_objectives; -mod test_packed_token_id; -mod test_renderer; -mod test_settings; -mod test_skills; -mod test_structs_coverage; -mod test_token_state; +mod test_gas_bench; +mod test_token; diff --git a/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo new file mode 100644 index 00000000..3084493c --- /dev/null +++ b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo @@ -0,0 +1,294 @@ +// Gas benchmarks: MinigameTokenComponent (self-bound in the one-address +// StandardGameMock — game and token are the same contract) vs the legacy +// CoreTokenComponent in its deployed-denshokan configuration (multi-game +// registry + all extensions). +// +// Method: paired tests. Each `*_baseline` test performs setup only; each op +// test repeats the measured operation 10 times on top of the same setup. +// Per-op cost = (op_test_l2_gas - baseline_l2_gas) / 10. Deployment noise +// cancels out within a pair; snforge prints l2_gas per test. +// +// Caveats when reading the numbers: +// * MockGame's `game_over()`/`score()` return from trivial storage. On a real +// game (e.g. death mountain) each callback re-runs full asset loading — +// measured on mainnet at ~1.56M L2 gas per callback — so the real +// `update_game` vs `refresh_metadata` gap is far larger than shown here. +// * FullTokenContract does not include EnumerableComponent; the deployed +// denshokan does, adding two storage writes per mint and per transfer on +// top of the legacy-token numbers. + +use game_components_test_common::mocks::standard_game_mock::{ + IStandardGameMockDispatcher, IStandardGameMockDispatcherTrait, +}; +use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; +use snforge_std::{ + CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare, + start_cheat_block_timestamp, +}; +use starknet::ContractAddress; +use crate::registry::interface::{IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait}; +use crate::token::interface::{IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait}; +use crate::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, +}; + +const START_TIME: u64 = 1000; +const END_TIME: u64 = 100000; + +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} + +fn ALICE() -> ContractAddress { + addr('ALICE') +} + +fn OWNER() -> ContractAddress { + addr('OWNER') +} + +// ================================================================================================ +// SETUP +// ================================================================================================ + +fn deploy_mock_game() -> ContractAddress { + let contract = declare("MockGame").unwrap().contract_class(); + let (contract_address, _) = contract.deploy(@array![]).unwrap(); + contract_address +} + +/// One-address shape: the StandardGameMock contract is both the game and the +/// token, so the returned "game" address is the token contract itself. +fn setup_standard() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddress) { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + let game_fee_recipient: starknet::ContractAddress = 'FEE_RECIPIENT'.try_into().unwrap(); + game_fee_recipient.serialize(ref calldata); + let owner: starknet::ContractAddress = 'OWNER'.try_into().unwrap(); + owner.serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + start_cheat_block_timestamp(contract_address, START_TIME); + ( + IMinigameTokenDispatcher { contract_address }, + ERC721ABIDispatcher { contract_address }, + contract_address, + ) +} + +/// Legacy token in the deployed-denshokan shape: multi-game registry with the +/// mock game registered, all optional extensions compiled in. +fn setup_legacy() -> (IMinigameTokenLegacyDispatcher, ERC721ABIDispatcher, ContractAddress) { + let game = deploy_mock_game(); + + let registry_class = declare("MinigameRegistryContract").unwrap().contract_class(); + let mut registry_calldata: Array = array![]; + let reg_name: ByteArray = "GameCreatorToken"; + let reg_symbol: ByteArray = "GCT"; + let reg_base_uri: ByteArray = ""; + reg_name.serialize(ref registry_calldata); + reg_symbol.serialize(ref registry_calldata); + reg_base_uri.serialize(ref registry_calldata); + registry_calldata.append(1); // event_relayer_address: None + let (registry_address, _) = registry_class.deploy(@registry_calldata).unwrap(); + + // register_game records the caller as the game contract + let registry = IMinigameRegistryDispatcher { contract_address: registry_address }; + cheat_caller_address(registry_address, game, CheatSpan::TargetCalls(1)); + registry + .register_game( + OWNER(), + "MockGame", + "d", + "dev", + "pub", + "genre", + "img", + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + 1, + Option::None, + Option::None, + ); + + let token_class = declare("FullTokenContract").unwrap().contract_class(); + let mut token_calldata: Array = array![]; + let name: ByteArray = "FullToken"; + let symbol: ByteArray = "FULL"; + let base_uri: ByteArray = "https://full.test/"; + name.serialize(ref token_calldata); + symbol.serialize(ref token_calldata); + base_uri.serialize(ref token_calldata); + OWNER().serialize(ref token_calldata); + OWNER().serialize(ref token_calldata); // royalty receiver + let royalty_fraction: u128 = 500; + royalty_fraction.serialize(ref token_calldata); + token_calldata.append(0); // game_registry_address: Some + registry_address.serialize(ref token_calldata); + let (token_address, _) = token_class.deploy(@token_calldata).unwrap(); + start_cheat_block_timestamp(token_address, START_TIME); + ( + IMinigameTokenLegacyDispatcher { contract_address: token_address }, + ERC721ABIDispatcher { contract_address: token_address }, + game, + ) +} + +fn mint_standard(token: IMinigameTokenDispatcher, _game: ContractAddress, salt: u16) -> felt252 { + // Standard mint — no game address (self-bound); the restored legacy-token + // params (objective/context/client_url/paymaster/metadata) neutral, to + // stay comparable with the legacy-token bench call below. + token + .mint( + Option::Some('bench'), + Option::None, + Option::Some(START_TIME), + Option::Some(END_TIME), + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + salt, + 0, + ) +} + +fn mint_legacy(token: IMinigameTokenLegacyDispatcher, game: ContractAddress, salt: u16) -> felt252 { + token + .mint( + game, + Option::Some('bench'), + Option::None, + Option::Some(START_TIME), + Option::Some(END_TIME), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + salt, + 0, + ) +} + +// ================================================================================================ +// BASELINES (setup only) +// ================================================================================================ + +#[test] +fn bench_standard_deploy_baseline() { + let (_, _, _) = setup_standard(); +} + +#[test] +fn bench_legacy_deploy_baseline() { + let (_, _, _) = setup_legacy(); +} + +// ================================================================================================ +// MINT — first mint (x1, cold minter registration) and x10 (9 warm mints) +// ================================================================================================ + +#[test] +fn bench_standard_mint_x1() { + let (token, _, game) = setup_standard(); + mint_standard(token, game, 0); +} + +#[test] +fn bench_legacy_mint_x1() { + let (token, _, game) = setup_legacy(); + mint_legacy(token, game, 0); +} + +#[test] +fn bench_standard_mint_x10() { + let (token, _, game) = setup_standard(); + let mut salt: u16 = 0; + while salt < 10 { + mint_standard(token, game, salt); + salt += 1; + } +} + +#[test] +fn bench_legacy_mint_x10() { + let (token, _, game) = setup_legacy(); + let mut salt: u16 = 0; + while salt < 10 { + mint_legacy(token, game, salt); + salt += 1; + } +} + +// ================================================================================================ +// PER-ACTION GUARD — full: owner_of + assert_is_playable (2 calls, as +// death-mountain's game_core does today) vs standard: assert_owner_and_playable — +// an internal call in the real one-address shape, exercised here through the +// game mock's single external entrypoint (1 call) +// ================================================================================================ + +#[test] +fn bench_standard_guard_x10() { + let (token, _, game) = setup_standard(); + let token_id = mint_standard(token, game, 0); + let game_mock = IStandardGameMockDispatcher { contract_address: token.contract_address }; + let mut i: u32 = 0; + while i < 10 { + game_mock.assert_owner_and_playable(token_id, ALICE()); + i += 1; + } +} + +#[test] +fn bench_legacy_guard_x10() { + let (token, erc721, game) = setup_legacy(); + let token_id = mint_legacy(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + let owner = erc721.owner_of(token_id.into()); + assert!(owner == ALICE(), "owner check"); + token.assert_is_playable(token_id); + i += 1; + } +} + +// ================================================================================================ +// POST-ACTION — full: update_game (SRC5 + registry resolve + game_over + +// score callbacks + minter SRC5 probe) vs standard: refresh_metadata (event only) +// ================================================================================================ + +#[test] +fn bench_standard_post_action_x10() { + let (token, _, game) = setup_standard(); + let token_id = mint_standard(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + token.refresh_metadata(token_id); + i += 1; + } +} + +#[test] +fn bench_legacy_post_action_x10() { + let (token, _, game) = setup_legacy(); + let token_id = mint_legacy(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + token.update_game(token_id); + i += 1; + } +} diff --git a/packages/embeddable_game_standard/src/token/tests/test_token.cairo b/packages/embeddable_game_standard/src/token/tests/test_token.cairo new file mode 100644 index 00000000..98fa2915 --- /dev/null +++ b/packages/embeddable_game_standard/src/token/tests/test_token.cairo @@ -0,0 +1,993 @@ +use game_components_interfaces::structs::metagame::{GameContext, GameContextDetails}; +use game_components_interfaces::token::game_fee::{ + DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameFeeTerms, IMINIGAME_TOKEN_GAME_FEE_ID, + IMinigameTokenGameFeeDispatcher, IMinigameTokenGameFeeDispatcherTrait, default_license, +}; +use game_components_interfaces::token::minter::{ + IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, +}; +use game_components_test_common::mocks::standard_game_mock::{ + IStandardGameMockDispatcher, IStandardGameMockDispatcherTrait, +}; +use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use snforge_std::{ + CheatSpan, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, + cheat_caller_address, declare, mock_call, spy_events, start_cheat_block_timestamp, + start_cheat_transaction_hash, +}; +use starknet::ContractAddress; +use crate::token::interface::{ + IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +}; +use crate::token::minigame_token_component::MinigameTokenComponent; +use crate::token::packing::{ + unpack_end_delay, unpack_has_context, unpack_metadata, unpack_minted_at, unpack_minted_by, + unpack_objective_id, unpack_paymaster, unpack_salt, unpack_settings_id, unpack_soulbound, + unpack_start_delay, unpack_token_id, unpack_tx_hash, +}; +use crate::token_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; +use crate::token_legacy::structs::MintBatchRecipient; + +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} + +fn ALICE() -> ContractAddress { + addr('ALICE') +} + +fn BOB() -> ContractAddress { + addr('BOB') +} + +fn MINTER() -> ContractAddress { + addr('MINTER') +} + +fn FEE_RECIPIENT() -> ContractAddress { + addr('FEE_RECIPIENT') +} + +fn OWNER() -> ContractAddress { + addr('OWNER') +} + +/// Deploys ONE contract that is both the game and the token — the only +/// supported shape: the component is self-binding. +fn deploy_token() -> ( + IMinigameTokenDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, +) { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + FEE_RECIPIENT().serialize(ref calldata); + OWNER().serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + ( + IMinigameTokenDispatcher { contract_address }, + ERC721ABIDispatcher { contract_address }, + IMinigameTokenMinterDispatcher { contract_address }, + ) +} + +/// The embedding game's view of the same contract — used to exercise the +/// component's internal pre-action guard (`assert_owner_and_playable` moved +/// off the external ABI; the mock re-exposes it the way a real game consumes +/// it inside its entrypoints). +fn game_of(token: IMinigameTokenDispatcher) -> IStandardGameMockDispatcher { + IStandardGameMockDispatcher { contract_address: token.contract_address } +} + +/// Mint with the restored 12-arg shape, neutral values for the params a test +/// is not exercising (no objective/context/client_url, no paymaster, zero +/// metadata). There is still no game address — the token IS the game. +fn mint_basic( + token: IMinigameTokenDispatcher, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + to: ContractAddress, + soulbound: bool, + salt: u16, +) -> felt252 { + token + .mint( + player_name, + settings_id, + start, + end, + Option::None, + Option::None, + Option::None, + to, + soulbound, + false, + salt, + 0, + ) +} + +fn sample_context() -> GameContextDetails { + GameContextDetails { + name: "Tournament", + description: "A test tournament", + id: Option::Some(7), + context: array![GameContext { name: 'round', value: 1 }].span(), + } +} + +// ================================================================================================ +// DEPLOYMENT / INTERFACE REGISTRATION +// ================================================================================================ + +#[test] +fn test_deployment_and_interfaces() { + let (token, erc721, _) = deploy_token(); + + assert!(erc721.name() == "StandardToken", "Name mismatch"); + assert!(erc721.symbol() == "STD", "Symbol mismatch"); + + let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; + assert!( + src5.supports_interface(IMINIGAME_TOKEN_ID), "Should register the standard interface id", + ); + // SRC5 is honest: a standard token does NOT implement IMinigameTokenLegacy and no + // longer advertises the legacy token id. + assert!( + !src5.supports_interface(IMINIGAME_TOKEN_LEGACY_ID), + "Must NOT advertise the legacy token id", + ); +} + +// ================================================================================================ +// MINT — PACKED FIELDS +// ================================================================================================ + +#[test] +fn test_mint_packs_expected_fields() { + let (token, erc721, minter) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + let token_id = mint_basic( + token, + Option::Some('alice'), + Option::Some(42), + Option::Some(2000), + Option::Some(3000), + ALICE(), + true, + 7, + ); + + let packed = unpack_token_id(token_id); + assert!(packed.settings_id == 42, "settings_id mismatch"); + assert!(packed.minted_at == 1000, "minted_at mismatch"); + assert!(packed.start_delay == 1000, "start_delay mismatch"); + assert!(packed.end_delay == 1000, "end_delay mismatch"); + assert!(packed.soulbound, "soulbound flag should be set"); + assert!(packed.minted_by == 1, "First minter should pack id 1"); + assert!(packed.salt == 7, "salt mismatch"); + + // The high half is fully allocated (no reserved region); with the + // restored params neutral, everything above salt's top bit must be zero. + let raw: u256 = token_id.into(); + assert!(raw.high / 0x4000000 == 0, "neutral restored fields must decode as zero"); // 2^26 + + // Views resolve from the packed id / minter map + assert!(token.settings_id(token_id) == 42, "settings_id view mismatch"); + assert!(token.is_soulbound(token_id), "is_soulbound view mismatch"); + assert!(token.player_name(token_id) == 'alice', "player_name mismatch"); + assert!(token.minted_by(token_id) == 1, "First minter should get id 1"); + assert!(token.minted_by_address(token_id) == MINTER(), "minted_by_address mismatch"); + assert!(minter.get_minter_address(1) == MINTER(), "Minter registry mismatch"); + assert!(erc721.owner_of(token_id.into()) == ALICE(), "Owner mismatch"); +} + +#[test] +fn test_mint_defaults_and_metadata_view() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + + let metadata = token.token_metadata(token_id); + assert!(metadata.game_id == 0, "game_id should be 0"); + assert!(metadata.settings_id == 0, "settings_id should default 0"); + assert!(metadata.minted_at == 1000, "minted_at mismatch"); + assert!(metadata.lifecycle.start == 1000, "start clamps to mint time"); + assert!(metadata.lifecycle.end == 0, "no end means immortal"); + assert!(!metadata.soulbound, "not soulbound"); + // No mutable state exists — these are unconditionally false/0 + assert!(!metadata.game_over, "game_over must always be false"); + assert!(!metadata.completed_objective, "completed_objective must always be false"); + assert!(metadata.completed_at == 0, "completed_at must always be 0"); + // Neutral restored params decode as absent + assert!(metadata.objective_id == 0, "objective_id defaults to 0"); + assert!(!metadata.has_context, "has_context defaults to false"); + assert!(!metadata.paymaster, "paymaster defaults to false"); + assert!(metadata.metadata == 0, "u16 metadata field is always 0 (see mint_metadata)"); + assert!(token.objective_id(token_id) == 0, "objective_id view defaults to 0"); + assert!(token.mint_metadata(token_id) == 0, "mint_metadata defaults to 0"); + assert!(token.client_url(token_id) == "", "client_url defaults to empty"); + assert!(token.player_name(token_id) == 0, "No player name set"); +} + +#[test] +fn test_mint_past_start_clamps_to_now() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let token_id = mint_basic( + token, Option::None, Option::None, Option::Some(500), Option::Some(2000), ALICE(), false, 0, + ); + + let metadata = token.token_metadata(token_id); + assert!(metadata.lifecycle.start == 1000, "Past start should clamp to mint time"); + assert!(metadata.lifecycle.end == 2000, "End must reconstruct to the caller's value"); +} + +#[test] +fn test_mint_unique_ids_by_salt_and_minter() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + // Same params, same block, same caller — salt must disambiguate + let id_a = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + let id_b = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 1, + ); + assert!(id_a != id_b, "Salt must produce distinct token ids"); + + // Second distinct caller gets minter id 2 + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + let id_c = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + assert!(token.minted_by(id_c) == 2, "Second minter should get id 2"); + // Repeat caller keeps its id + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + let id_d = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 1, + ); + assert!(token.minted_by(id_d) == 2, "Repeat minter keeps id"); +} + +// ================================================================================================ +// MINT — LIFECYCLE VALIDATION +// ================================================================================================ + +#[test] +#[should_panic(expected: "MinigameToken: Lifecycle end must be in the future and after start")] +fn test_mint_rejects_past_end() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + mint_basic( + token, Option::None, Option::None, Option::None, Option::Some(900), ALICE(), false, 0, + ); +} + +#[test] +#[should_panic(expected: "Lifecycle: Start time cannot be greater than end time")] +fn test_mint_rejects_start_after_end() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + mint_basic( + token, + Option::None, + Option::None, + Option::Some(3000), + Option::Some(2000), + ALICE(), + false, + 0, + ); +} + +// ================================================================================================ +// PLAYABILITY — LIFECYCLE WINDOW ONLY +// ================================================================================================ + +#[test] +fn test_playability_follows_lifecycle_window() { + let (token, _, _) = deploy_token(); + let game = game_of(token); + start_cheat_block_timestamp(token.contract_address, 1000); + + let token_id = mint_basic( + token, + Option::None, + Option::None, + Option::Some(2000), + Option::Some(3000), + ALICE(), + false, + 0, + ); + + assert!(!token.is_playable(token_id), "Not playable before window opens"); + + start_cheat_block_timestamp(token.contract_address, 2000); + assert!(token.is_playable(token_id), "Playable at window start"); + // The embedding game's internal pre-action guard agrees with the view + game.assert_owner_and_playable(token_id, ALICE()); + + start_cheat_block_timestamp(token.contract_address, 3000); + assert!(!token.is_playable(token_id), "Expired at window end"); +} + +#[test] +fn test_immortal_token_always_playable() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + start_cheat_block_timestamp(token.contract_address, 99999999); + assert!(token.is_playable(token_id), "No end means playable forever"); +} + +// ================================================================================================ +// INTERNAL GUARD (assert_owner_and_playable — via the embedding game mock) +// ================================================================================================ + +#[test] +#[should_panic(expected: "MinigameToken: Token is not playable - game has expired")] +fn test_guard_panics_after_expiry() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::Some(2000), ALICE(), false, 0, + ); + start_cheat_block_timestamp(token.contract_address, 2000); + game_of(token).assert_owner_and_playable(token_id, ALICE()); +} + +#[test] +#[should_panic(expected: "MinigameToken: Token is not playable - game has not started")] +fn test_guard_panics_before_start() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + let token_id = mint_basic( + token, + Option::None, + Option::None, + Option::Some(2000), + Option::Some(3000), + ALICE(), + false, + 0, + ); + game_of(token).assert_owner_and_playable(token_id, ALICE()); +} + +#[test] +#[should_panic(expected: "MinigameToken: Address is not owner of token")] +fn test_guard_rejects_wrong_owner() { + let (token, _, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + game_of(token).assert_owner_and_playable(token_id, BOB()); +} + +#[test] +#[should_panic(expected: "MinigameToken: Address is not owner of token")] +fn test_guard_rejects_nonexistent_token() { + let (token, _, _) = deploy_token(); + game_of(token).assert_owner_and_playable(12345, ALICE()); +} + +#[test] +#[should_panic(expected: "MinigameToken: Expected owner cannot be zero")] +fn test_guard_rejects_zero_owner() { + let (token, _, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + game_of(token).assert_owner_and_playable(token_id, addr(0)); +} + +// ================================================================================================ +// SOULBOUND +// ================================================================================================ + +#[test] +#[should_panic(expected: "Token is soulbound and cannot be transferred")] +fn test_soulbound_transfer_blocked() { + let (token, erc721, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), true, 0, + ); + cheat_caller_address(token.contract_address, ALICE(), CheatSpan::TargetCalls(1)); + erc721.transfer_from(ALICE(), BOB(), token_id.into()); +} + +#[test] +fn test_non_soulbound_transfer_allowed() { + let (token, erc721, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + cheat_caller_address(token.contract_address, ALICE(), CheatSpan::TargetCalls(1)); + erc721.transfer_from(ALICE(), BOB(), token_id.into()); + assert!(erc721.owner_of(token_id.into()) == BOB(), "Transfer should succeed"); +} + +// ================================================================================================ +// METADATA REFRESH + PLAYER NAME +// ================================================================================================ + +#[test] +fn test_refresh_metadata_emits_event() { + let (token, _, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + + let mut spy = spy_events(); + token.refresh_metadata(token_id); + spy + .assert_emitted( + @array![ + ( + token.contract_address, + MinigameTokenComponent::Event::MetadataUpdate( + MinigameTokenComponent::MetadataUpdate { token_id: token_id.into() }, + ), + ), + ], + ); +} + +#[test] +fn test_update_player_name_by_owner() { + let (token, _, _) = deploy_token(); + let token_id = mint_basic( + token, Option::Some('old'), Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + cheat_caller_address(token.contract_address, ALICE(), CheatSpan::TargetCalls(1)); + token.update_player_name(token_id, 'new'); + assert!(token.player_name(token_id) == 'new', "Player name should update"); +} + +#[test] +#[should_panic(expected: "MinigameToken: Caller is not owner of token")] +fn test_update_player_name_rejects_non_owner() { + let (token, _, _) = deploy_token(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + cheat_caller_address(token.contract_address, BOB(), CheatSpan::TargetCalls(1)); + token.update_player_name(token_id, 'new'); +} + +// ================================================================================================ +// BATCH MINT +// ================================================================================================ + +fn batch_neutral( + token: IMinigameTokenDispatcher, recipients: Array, salt: u16, +) -> Array { + token + .mint_batch_recipients( + Option::Some('bench'), + Option::Some(5), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + recipients, + false, + false, + salt, + 0, + ) +} + +#[test] +fn test_mint_batch_recipients_counts_owners_and_salts() { + let (token, erc721, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + let ids = batch_neutral( + token, + array![ + MintBatchRecipient { to: ALICE(), count: 2 }, + MintBatchRecipient { to: BOB(), count: 1 }, + ], + 7, + ); + + assert!(ids.len() == 3, "Should mint 3 tokens"); + let id_a = *ids.at(0); + let id_b = *ids.at(1); + let id_c = *ids.at(2); + assert!(id_a != id_b && id_b != id_c && id_a != id_c, "Token ids must be distinct"); + assert!(erc721.owner_of(id_a.into()) == ALICE(), "First token to ALICE"); + assert!(erc721.owner_of(id_b.into()) == ALICE(), "Second token to ALICE"); + assert!(erc721.owner_of(id_c.into()) == BOB(), "Third token to BOB"); + + // Global salt counter across the batch, minter registered once + assert!(unpack_salt(id_a) == 7 && unpack_salt(id_b) == 8 && unpack_salt(id_c) == 9, "salts"); + let mut i: u32 = 0; + while i < ids.len() { + let id = *ids.at(i); + assert!(token.minted_by(id) == 1, "All share minter id 1"); + assert!(token.settings_id(id) == 5, "Shared settings id"); + assert!(token.player_name(id) == 'bench', "Shared player name"); + i += 1; + } +} + +#[test] +#[should_panic(expected: "MinigameToken: salt overflow (salt + total tokens - 1 must be <= 65535)")] +fn test_mint_batch_recipients_rejects_salt_overflow() { + let (token, _, _) = deploy_token(); + // 65533 + 4 - 1 = 65536 > 0xFFFF — one past the 16-bit salt field. + batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 4 }], 65533); +} + +#[test] +fn test_mint_batch_recipients_salt_at_16_bit_boundary() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 65533 + 3 - 1 = 65535 == 0xFFFF — exactly fills the widened 16-bit + // field (would have overflowed the legacy token's 10-bit salt long ago). + let ids = batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 3 }], 65533); + assert!(ids.len() == 3, "Should mint 3 tokens"); + assert!(unpack_salt(*ids.at(0)) == 65533, "first salt"); + assert!(unpack_salt(*ids.at(2)) == 0xFFFF, "last salt fills the 16-bit field"); +} + +#[test] +#[should_panic(expected: "MinigameToken: recipients array cannot be empty")] +fn test_mint_batch_recipients_rejects_empty() { + let (token, _, _) = deploy_token(); + batch_neutral(token, array![], 0); +} + +#[test] +#[should_panic(expected: "MinigameToken: per-recipient count must be > 0")] +fn test_mint_batch_recipients_rejects_zero_count() { + let (token, _, _) = deploy_token(); + batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 0 }], 0); +} + +// ================================================================================================ +// ECOSYSTEM INTEGRATION (metagame assert_game_registered) +// ================================================================================================ + +/// Positive path: `assert_game_registered` now probes the token's SRC5 for +/// `IMINIGAME_TOKEN_ID` first (standard tokens expose no registry views). A +/// self-bound standard deployment IS its own game: `token_address()` returns +/// itself, the standard id matches, and the check reduces to a trivially-true +/// address equality. +#[test] +fn test_assert_game_registered_accepts_self_bound_game() { + let (token, _, _) = deploy_token(); + crate::metagame::metagame::assert_game_registered(token.contract_address); +} + +/// Negative path: a game whose `token_address()` points at some OTHER standard +/// token is not a valid pairing — self-binding means the only accepted answer +/// is the game's own address. A second StandardGameMock cannot express this +/// misconfiguration (it always returns itself), so the fake game is a mocked +/// address pointing at a real standard deployment: the SRC5 probe finds the +/// id for real, then the address equality fake_game == token fails. +#[test] +#[should_panic(expected: "Game is not registered")] +fn test_assert_game_registered_rejects_game_not_paired_with_standard_token() { + let (token, _, _) = deploy_token(); + + let fake_game = addr('FAKE_GAME'); + mock_call(fake_game, selector!("token_address"), token.contract_address, 1); + crate::metagame::metagame::assert_game_registered(fake_game); +} + +// ================================================================================================ +// PACKING — LAYOUT AND HELPERS +// ================================================================================================ + +#[test] +fn test_helper_unpackers_agree_with_full_unpack() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1234); + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + let token_id = mint_basic( + token, Option::None, Option::Some(9), Option::None, Option::Some(9999), ALICE(), true, 3, + ); + + // Token ids use the standard 251-bit layout, so the standalone helper + // unpackers (what game/dungeon contracts use on their side) must agree + // with the full unpack. + let packed = unpack_token_id(token_id); + assert!(unpack_minted_at(token_id) == packed.minted_at, "minted_at helper mismatch"); + assert!(unpack_start_delay(token_id) == packed.start_delay, "start_delay helper mismatch"); + assert!(unpack_end_delay(token_id) == packed.end_delay, "end_delay helper mismatch"); + assert!(unpack_settings_id(token_id) == packed.settings_id, "settings_id helper mismatch"); + assert!(unpack_minted_by(token_id) == packed.minted_by, "minted_by helper mismatch"); + assert!(unpack_soulbound(token_id) == packed.soulbound, "soulbound helper mismatch"); + assert!(unpack_tx_hash(token_id) == packed.tx_hash, "tx_hash helper mismatch"); + assert!(unpack_salt(token_id) == packed.salt, "salt helper mismatch"); + assert!(unpack_paymaster(token_id) == packed.paymaster, "paymaster helper mismatch"); + assert!(unpack_has_context(token_id) == packed.has_context, "has_context helper mismatch"); + assert!(unpack_objective_id(token_id) == packed.objective_id, "objective_id helper mismatch"); + assert!(unpack_metadata(token_id) == packed.metadata, "metadata helper mismatch"); + assert!(packed.minted_at == 1234 && packed.settings_id == 9, "field values"); + assert!(packed.soulbound && packed.salt == 3 && packed.minted_by == 1, "field values"); +} + +/// Bit-exact layout proof: with every input pinned (including the tx hash), +/// the minted id must equal the arithmetic reconstruction of the documented +/// id layout — low: minted_at | start_delay<<35 | end_delay<<60 | +/// settings_id<<85 | minted_by<<101 | soulbound<<127; high: tx_hash | +/// salt<<10 | paymaster<<26 | has_context<<27 | objective_id<<28 | +/// metadata<<58. The high half is fully allocated — no reserved region. +#[test] +fn test_layout_bit_positions_exact() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + start_cheat_transaction_hash(token.contract_address, 0x123456789abcdef); + cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); + + let token_id = token + .mint( + Option::None, + Option::Some(0xABCD), + Option::Some(2000), + Option::Some(5000), + Option::Some(0x1ABCDE), + Option::Some(sample_context()), + Option::None, + ALICE(), + true, + true, + 0x1234, + 0x123456789ABCD, + ); + + // minted_at=1000, start_delay=1000, end_delay=3000, settings_id=0xABCD, + // minted_by=1 (first minter), soulbound=1, tx_hash=0x1ef (last 10 bits + // of 0x...cdef), salt=0x1234, paymaster=1, has_context=1 (context + // supplied), objective_id=0x1ABCDE, metadata=0x123456789ABCD. + let expected_low: u128 = 1000 + + 1000 * 0x800000000 // start_delay << 35 + + 3000 * 0x1000000000000000 // end_delay << 60 + + 0xABCD * 0x2000000000000000000000 // settings_id << 85 + + 1 * 0x20000000000000000000000000 // minted_by << 101 + + 0x80000000000000000000000000000000; // soulbound << 127 + let expected_high: u128 = 0x1ef + + 0x1234 * 0x400 // salt << 10 + + 0x4000000 // paymaster << 26 + + 0x8000000 // has_context << 27 + + 0x1ABCDE * 0x10000000 // objective_id << 28 + + 0x123456789ABCD * 0x400000000000000; // metadata << 58 + let expected: felt252 = u256 { low: expected_low, high: expected_high }.try_into().unwrap(); + assert!(token_id == expected, "id layout bit positions must match the documented table"); +} + +#[test] +fn test_mint_accepts_settings_id_at_16_bit_boundary() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + let token_id = mint_basic( + token, Option::None, Option::Some(0xFFFF), Option::None, Option::None, ALICE(), false, 0, + ); + assert!(token.settings_id(token_id) == 0xFFFF, "boundary settings_id roundtrip"); +} + +#[test] +#[should_panic(expected: "PackedTokenId: settings_id exceeds 16-bit limit")] +fn test_mint_rejects_settings_id_over_16_bits() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 0x10000 fit the legacy token's 30-bit field but exceeds the standard 16-bit + // field — must now be rejected at mint. + mint_basic( + token, Option::None, Option::Some(0x10000), Option::None, Option::None, ALICE(), false, 0, + ); +} + +// ================================================================================================ +// RESTORED MINT PARAMS — objective_id / context / client_url / paymaster / metadata +// ================================================================================================ + +/// Mint helper that exercises exactly the restored params, neutral elsewhere. +fn mint_restored( + token: IMinigameTokenDispatcher, + objective_id: Option, + context: Option, + client_url: Option, + paymaster: bool, + salt: u16, + metadata: u128, +) -> felt252 { + token + .mint( + Option::None, + Option::None, + Option::None, + Option::None, + objective_id, + context, + client_url, + ALICE(), + false, + paymaster, + salt, + metadata, + ) +} + +/// The restored packed fields roundtrip through mint: id bits, standalone +/// helpers, ABI views and the shared TokenMetadata struct all agree. +#[test] +fn test_mint_restored_fields_roundtrip() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let token_id = mint_restored( + token, + Option::Some(123456), + Option::Some(sample_context()), + Option::None, + true, + 0, + 0xDEADBEEFCAFE, + ); + + let packed = unpack_token_id(token_id); + assert!(packed.objective_id == 123456, "objective_id pack mismatch"); + assert!(packed.has_context, "has_context bit should be set"); + assert!(packed.paymaster, "paymaster bit should be set"); + assert!(packed.metadata == 0xDEADBEEFCAFE, "metadata pack mismatch"); + + // ABI views + assert!(token.objective_id(token_id) == 123456, "objective_id view mismatch"); + assert!(token.mint_metadata(token_id) == 0xDEADBEEFCAFE, "mint_metadata view mismatch"); + + // Shared TokenMetadata struct: objective_id/has_context/paymaster are + // populated from the id; the u16 metadata field CANNOT hold the 65-bit + // value and stays 0 (never truncated) — mint_metadata is the real view. + // objective_id is inert data the game interprets: the standard token has no + // completion machinery, so completed_objective stays false. + let md = token.token_metadata(token_id); + assert!(md.objective_id == 123456, "TokenMetadata.objective_id mismatch"); + assert!(md.has_context, "TokenMetadata.has_context mismatch"); + assert!(md.paymaster, "TokenMetadata.paymaster mismatch"); + assert!(md.metadata == 0, "TokenMetadata.metadata must be 0, not a truncation"); + assert!(!md.completed_objective, "completed_objective stays always-false"); +} + +#[test] +fn test_mint_accepts_field_boundaries() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + // Every restored field at its maximum: objective_id 2^30-1, metadata + // 2^65-1 (filling the high half's topmost bit — the layout has no + // reserved region), both flag bits set, salt filling its 16 bits. + let token_id = mint_restored( + token, + Option::Some(0x3FFFFFFF), + Option::Some(sample_context()), + Option::None, + true, + 0xFFFF, + 0x1FFFFFFFFFFFFFFFF, + ); + assert!(token.objective_id(token_id) == 0x3FFFFFFF, "boundary objective_id roundtrip"); + assert!(token.mint_metadata(token_id) == 0x1FFFFFFFFFFFFFFFF, "boundary metadata roundtrip"); + + // metadata is the topmost high field: with it maxed, the quotient above + // objective_id's top bit must be exactly the metadata value — nothing + // sits above it. + let raw: u256 = token_id.into(); + assert!( + raw.high / 0x400000000000000 == 0x1FFFFFFFFFFFFFFFF, + "metadata occupies the entire top of the high half", + ); // 2^58 +} + +#[test] +#[should_panic(expected: "PackedTokenId: objective_id exceeds 30-bit limit")] +fn test_mint_rejects_objective_id_over_30_bits() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 2^30 — one past the 30-bit field. + mint_restored(token, Option::Some(0x40000000), Option::None, Option::None, false, 0, 0); +} + +#[test] +#[should_panic(expected: "PackedTokenId: metadata exceeds 65-bit limit")] +fn test_mint_rejects_metadata_over_65_bits() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 2^65 — one past the 65-bit field. + mint_restored(token, Option::None, Option::None, Option::None, false, 0, 0x20000000000000000); +} + +/// client_url is storage-backed exactly as on the legacy token: written when +/// Some, readable via the view, empty ByteArray default when absent. +#[test] +fn test_client_url_stored_and_empty_default() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let with_url = mint_restored( + token, Option::None, Option::None, Option::Some("https://play.example/game"), false, 0, 0, + ); + assert!(token.client_url(with_url) == "https://play.example/game", "client_url view mismatch"); + + let without_url = mint_restored(token, Option::None, Option::None, Option::None, false, 1, 0); + assert!(token.client_url(without_url) == "", "client_url should default to empty"); +} + +/// context sets the id's has_context bit only — the data itself is NOT stored +/// (legacy-token parity: its context hook was a documented no-op and token_uri +/// sourced context from the minter at render time). +#[test] +fn test_context_sets_has_context_bit_without_storage() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let with_context = mint_restored( + token, Option::None, Option::Some(sample_context()), Option::None, false, 0, 0, + ); + assert!(unpack_has_context(with_context), "has_context bit must be set"); + assert!(token.token_metadata(with_context).has_context, "metadata view agrees"); + // Nothing context-shaped was persisted: the only storage-backed views + // stay at their defaults. + assert!(token.client_url(with_context) == "", "no context data lands in storage"); + assert!(token.player_name(with_context) == 0, "no context data lands in storage"); + + let without_context = mint_restored( + token, Option::None, Option::None, Option::None, false, 1, 0, + ); + assert!(!unpack_has_context(without_context), "has_context bit must be clear"); +} + +/// Batch mints share the packed fields (has_context bit, objective, paymaster, +/// metadata) across all tokens, and the client_url — when Some — is written +/// per token. +#[test] +fn test_mint_batch_shares_restored_fields_and_url() { + let (token, _, _) = deploy_token(); + start_cheat_block_timestamp(token.contract_address, 1000); + + let ids = token + .mint_batch_recipients( + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(77), + Option::Some(sample_context()), + Option::Some("https://play.example/batch"), + array![ + MintBatchRecipient { to: ALICE(), count: 2 }, + MintBatchRecipient { to: BOB(), count: 1 }, + ], + false, + true, + 0, + 42, + ); + + assert!(ids.len() == 3, "Should mint 3 tokens"); + let mut i: u32 = 0; + while i < ids.len() { + let id = *ids.at(i); + assert!(unpack_has_context(id), "shared has_context bit"); + assert!(unpack_paymaster(id), "shared paymaster bit"); + assert!(token.objective_id(id) == 77, "shared objective_id"); + assert!(token.mint_metadata(id) == 42, "shared metadata"); + assert!(token.client_url(id) == "https://play.example/batch", "url written per token"); + i += 1; + } +} + +// ================================================================================================ +// CREATOR SURFACE (owner-administered payout identity) +// ================================================================================================ + +fn game_fee_of(token: IMinigameTokenDispatcher) -> IMinigameTokenGameFeeDispatcher { + IMinigameTokenGameFeeDispatcher { contract_address: token.contract_address } +} + +#[test] +fn test_game_fee_registered_with_defaults() { + let (token, _, _) = deploy_token(); + let game_fee = game_fee_of(token); + + let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; + assert!( + src5.supports_interface(IMINIGAME_TOKEN_GAME_FEE_ID), + "Should register the game-fee interface id", + ); + + assert!(game_fee.game_fee_recipient() == FEE_RECIPIENT(), "Recipient address mismatch"); + let info = game_fee.game_fee_terms(); + let expected = GameFeeTerms { + recipient: FEE_RECIPIENT(), license: default_license(), fee_numerator: DEFAULT_GAME_FEE_BPS, + }; + assert!(info == expected, "Info should carry the ecosystem defaults"); +} + +#[test] +fn test_owner_rotates_recipient_and_sets_fee() { + let (token, _, _) = deploy_token(); + let game_fee = game_fee_of(token); + + cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(2)); + game_fee.set_game_fee_recipient(BOB()); + game_fee.set_game_fee("Custom license", 1000); + + let info = game_fee.game_fee_terms(); + assert!(info.recipient == BOB(), "Rotation should take effect"); + assert!(info.license == "Custom license", "License should update"); + assert!(info.fee_numerator == 1000, "Fee should update"); +} + +#[test] +#[should_panic(expected: 'Caller is not the owner')] +fn test_recipient_itself_cannot_rotate() { + // The stored recipient is a payout sink, not an admin: only the contract + // owner rotates it. + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, FEE_RECIPIENT(), CheatSpan::TargetCalls(1)); + game_fee_of(token).set_game_fee_recipient(BOB()); +} + +#[test] +#[should_panic(expected: 'Caller is not the owner')] +fn test_non_owner_cannot_set_fee() { + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, ALICE(), CheatSpan::TargetCalls(1)); + game_fee_of(token).set_game_fee("hijack", 0); +} + +#[test] +#[should_panic(expected: "MinigameToken: Fee recipient cannot be zero")] +fn test_rotation_to_zero_rejected() { + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(1)); + game_fee_of(token).set_game_fee_recipient(addr(0)); +} + +#[test] +#[should_panic(expected: "MinigameToken: Fee numerator exceeds denominator")] +fn test_fee_above_denominator_rejected() { + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(1)); + game_fee_of(token).set_game_fee("too greedy", FEE_DENOMINATOR + 1); +} + +#[test] +fn test_zero_recipient_deploy_rejected() { + let contract = declare("StandardGameMock").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "StandardToken"; + let symbol: ByteArray = "STD"; + let base_uri: ByteArray = "https://token.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + addr(0).serialize(ref calldata); + OWNER().serialize(ref calldata); + assert!(contract.deploy(@calldata).is_err(), "Zero recipient must fail the constructor"); +} diff --git a/packages/embeddable_game_standard/src/token_legacy.cairo b/packages/embeddable_game_standard/src/token_legacy.cairo new file mode 100644 index 00000000..d93159aa --- /dev/null +++ b/packages/embeddable_game_standard/src/token_legacy.cairo @@ -0,0 +1,10 @@ +pub mod extensions; +pub mod interface; +pub mod noop_traits; +pub mod structs; + +#[cfg(test)] +mod tests; +pub mod token; +pub mod token_component; +pub mod traits; diff --git a/packages/embeddable_game_standard/src/token_legacy/AGENTS.md b/packages/embeddable_game_standard/src/token_legacy/AGENTS.md new file mode 100644 index 00000000..d17415a8 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_legacy/AGENTS.md @@ -0,0 +1,98 @@ +## Token Legacy Package - MinigameTokenLegacy (ERC721) + +ERC721 NFT representing playable game instances — the ORIGINAL multi-game +minigame token, now the LEGACY module. Kept as-is (component names, storage, +selectors, interface-id values all frozen) for deployed denshokan. The +minigame token STANDARD is the `token` module (`MinigameTokenComponent`, +self-bound single-game token). + +### Core Interface (IMinigameTokenLegacy) + +**Interface ID:** `IMINIGAME_TOKEN_LEGACY_ID = 0x246f614bd76b91c378a91877851f2ccdb99278e9fb77c782a22355059ce9906` +(the value is frozen — deployed denshokan registers it on-chain from when +this trait was named `IMinigameToken`) + +| Method | Signature | Description | +| ----------------------- | ---------------------------------------------------------------------------------------- | -------------------------------------- | +| token_metadata | `(token_id: felt252) -> TokenMetadata` | Get full token metadata | +| is_playable | `(token_id: felt252) -> bool` | Check if token can be played | +| mint | `(...params) -> felt252` | Mint a single token | +| mint_batch_recipients | `(...shared params, recipients: Array, ...) -> Array` | Batch mint, per-recipient counts | +| update_game | `(token_id: felt252)` | Sync token state from game | + +**Batch views:** `*_batch` variants for all view functions (token_metadata, is_playable, settings_id, etc.) + +### Extension Interfaces + +| Extension | Interface ID | Key Methods | +| ---------- | ------------ | ------------------------------------------------------------- | +| Minter | `0x2198...` | get_minter_address(), get_minter_id(), minter_exists() | +| Objectives | `0x2c9b...` | create_objective() | +| Settings | `0x229b...` | create_settings() | +| Renderer | `0x2899...` | get_renderer(), has_custom_renderer(), reset_token_renderer() | +| Context | - | Game context attachment via GameContextDetails | + +### Storage Optimization - StorePacking + +TokenMetadata packed into single felt252 (219 bits): + +``` +| Bits 0-29 | game_id | 30 bits | +| Bits 30-64 | minted_at | 35 bits | +| Bits 65-96 | settings_id | 32 bits | +| Bits 97-166 | lifecycle | 70 bits | +| Bits 167-206| minted_by | 40 bits | +| Bits 207-210| flags | 4 bits | +| Bits 211-240| objective_id | 30 bits | +``` + +**Gas savings:** Reduces from ~6 storage slots to 1 slot per token. + +### PackedTokenId (Immutable in token_id) + +Token ID encodes immutable metadata (251 bits) eliminating storage reads: + +- game_id, minted_by, settings_id, minted_at +- lifecycle delays, objective_id, soulbound, has_context +- tx_hash (collision protection), salt (multicall protection) + +### Libs + +| File | Purpose | +| -------------------------- | ------------------------------------------- | +| `libs/lifecycle.cairo` | Lifecycle validation (start/end timestamps) | +| `libs/token_state.cairo` | Playability checks, state transitions | +| `libs/address_utils.cairo` | Address manipulation utilities | + +### Key Structs + +```cairo +struct TokenMetadata { + game_id: u64, minted_at: u64, settings_id: u32, + lifecycle: Lifecycle, minted_by: u64, soulbound: bool, + game_over: bool, completed_objective: bool, + has_context: bool, objective_id: u32 +} + +struct Lifecycle { start: u64, end: u64 } +struct MintBatchRecipient { to: ContractAddress, count: u16 } +``` + +### Extension Directory Structure + +``` +src/extensions/ + minter/ - Minting authorization + objectives/ - Objective tracking + settings/ - Game settings + renderer/ - Custom rendering + context/ - Game context +``` + +### Examples + +See `src/tests/examples/` for deployment patterns: + +- `minimal_optimized_example.cairo` - Minimal contract +- `full_token_contract.cairo` - All features enabled +- `single_game_token_contract.cairo` - Single game mode diff --git a/packages/embeddable_game_standard/src/token/CLAUDE.md b/packages/embeddable_game_standard/src/token_legacy/CLAUDE.md similarity index 100% rename from packages/embeddable_game_standard/src/token/CLAUDE.md rename to packages/embeddable_game_standard/src/token_legacy/CLAUDE.md diff --git a/packages/embeddable_game_standard/src/token/GEMINI.md b/packages/embeddable_game_standard/src/token_legacy/GEMINI.md similarity index 100% rename from packages/embeddable_game_standard/src/token/GEMINI.md rename to packages/embeddable_game_standard/src/token_legacy/GEMINI.md diff --git a/packages/embeddable_game_standard/src/token/README.md b/packages/embeddable_game_standard/src/token_legacy/README.md similarity index 100% rename from packages/embeddable_game_standard/src/token/README.md rename to packages/embeddable_game_standard/src/token_legacy/README.md diff --git a/packages/embeddable_game_standard/src/token/extensions.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/context.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/context.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/context.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/context.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/context/context.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/context/context.cairo similarity index 89% rename from packages/embeddable_game_standard/src/token/extensions/context/context.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/context/context.cairo index 75619aaf..aa0e9ac8 100644 --- a/packages/embeddable_game_standard/src/token/extensions/context/context.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/context/context.cairo @@ -3,8 +3,8 @@ pub mod ContextComponent { use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; use openzeppelin_introspection::src5::SRC5Component::{self, InternalTrait as SRC5InternalTrait}; use starknet::ContractAddress; - use crate::token::extensions::context::interface::IMINIGAME_TOKEN_CONTEXT_ID; - use crate::token::traits::OptionalContext; + use crate::token_legacy::extensions::context::interface::IMINIGAME_TOKEN_CONTEXT_ID; + use crate::token_legacy::traits::OptionalContext; #[storage] pub struct Storage {} diff --git a/packages/embeddable_game_standard/src/token/extensions/context/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/context/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/context/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/context/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/enumerable.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/enumerable.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/enumerable.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/enumerable.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/enumerable/enumerable.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/enumerable/enumerable.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/extensions/enumerable/enumerable.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/enumerable/enumerable.cairo index 3b25737d..668258f8 100644 --- a/packages/embeddable_game_standard/src/token/extensions/enumerable/enumerable.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/enumerable/enumerable.cairo @@ -18,7 +18,7 @@ pub mod EnumerableComponent { }; use starknet::ContractAddress; use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; - use crate::token::extensions::enumerable::interface::IENUMERABLE_OWNER_ID; + use crate::token_legacy::extensions::enumerable::interface::IENUMERABLE_OWNER_ID; // Internal storage uses felt252 for single-slot efficiency. // External interface converts u256 <-> felt252 at the boundary. diff --git a/packages/embeddable_game_standard/src/token/extensions/enumerable/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/enumerable/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/enumerable/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/enumerable/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/minter.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/minter.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/minter.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/minter.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/minter/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/minter/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/minter/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/minter/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/minter/minter.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/minter/minter.cairo similarity index 96% rename from packages/embeddable_game_standard/src/token/extensions/minter/minter.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/minter/minter.cairo index 39f3dce2..f7d608d4 100644 --- a/packages/embeddable_game_standard/src/token/extensions/minter/minter.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/minter/minter.cairo @@ -6,10 +6,10 @@ pub mod MinterComponent { use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use crate::token::extensions::minter::interface::{ + use crate::token_legacy::extensions::minter::interface::{ IMINIGAME_TOKEN_MINTER_ID, IMinigameTokenMinter, }; - use crate::token::traits::OptionalMinter; + use crate::token_legacy::traits::OptionalMinter; #[storage] pub struct Storage { diff --git a/packages/embeddable_game_standard/src/token/extensions/objectives.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/objectives.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/objectives.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/objectives.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/objectives/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/objectives/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/objectives/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/objectives/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/objectives/objectives.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/objectives/objectives.cairo similarity index 95% rename from packages/embeddable_game_standard/src/token/extensions/objectives/objectives.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/objectives/objectives.cairo index 48f997d3..3f086c67 100644 --- a/packages/embeddable_game_standard/src/token/extensions/objectives/objectives.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/objectives/objectives.cairo @@ -14,14 +14,14 @@ pub mod ObjectivesComponent { InternalTrait as SRC5InternalTrait, SRC5Impl, }; use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::token::extensions::objectives::interface::{ + use crate::token_legacy::extensions::objectives::interface::{ IMINIGAME_TOKEN_OBJECTIVES_ID, IMinigameTokenObjectives, }; - use crate::token::interface::{ - IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, IMinigameTokenDispatcher, - IMinigameTokenDispatcherTrait, + use crate::token_legacy::interface::{ + IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; - use crate::token::traits::OptionalObjectives; + use crate::token_legacy::traits::OptionalObjectives; #[storage] pub struct Storage {} @@ -65,7 +65,7 @@ pub mod ObjectivesComponent { ); // Check game address is supported - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: get_contract_address(), }; let is_single_game = game_address == minigame_token_dispatcher.game_address(); diff --git a/packages/embeddable_game_standard/src/token/extensions/renderer.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/renderer.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/renderer.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/renderer.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/renderer/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/renderer/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/renderer/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/renderer/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/renderer/renderer.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/renderer/renderer.cairo similarity index 93% rename from packages/embeddable_game_standard/src/token/extensions/renderer/renderer.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/renderer/renderer.cairo index ed6db679..bbf53c61 100644 --- a/packages/embeddable_game_standard/src/token/extensions/renderer/renderer.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/renderer/renderer.cairo @@ -10,13 +10,13 @@ pub mod RendererComponent { Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::token::extensions::renderer::interface::{ + use crate::token_legacy::extensions::renderer::interface::{ IMINIGAME_TOKEN_RENDERER_ID, IMinigameTokenRenderer, }; - use crate::token::token::address_utils; - use crate::token::token_component::CoreTokenComponent; - use crate::token::token_component::CoreTokenComponent::EventEmittersTrait; - use crate::token::traits::OptionalRenderer; + use crate::token_legacy::token::address_utils; + use crate::token_legacy::token_component::CoreTokenComponent; + use crate::token_legacy::token_component::CoreTokenComponent::EventEmittersTrait; + use crate::token_legacy::traits::OptionalRenderer; #[storage] pub struct Storage { diff --git a/packages/embeddable_game_standard/src/token/extensions/settings.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/settings.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/settings.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/settings.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/settings/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/settings/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/settings/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/settings/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/settings/settings.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/settings/settings.cairo similarity index 95% rename from packages/embeddable_game_standard/src/token/extensions/settings/settings.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/settings/settings.cairo index f03ccd10..58139839 100644 --- a/packages/embeddable_game_standard/src/token/extensions/settings/settings.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/settings/settings.cairo @@ -14,14 +14,14 @@ pub mod SettingsComponent { InternalTrait as SRC5InternalTrait, SRC5Impl, }; use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::token::extensions::settings::interface::{ + use crate::token_legacy::extensions::settings::interface::{ IMINIGAME_TOKEN_SETTINGS_ID, IMinigameTokenSettings, }; - use crate::token::interface::{ - IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, IMinigameTokenDispatcher, - IMinigameTokenDispatcherTrait, + use crate::token_legacy::interface::{ + IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; - use crate::token::traits::OptionalSettings; + use crate::token_legacy::traits::OptionalSettings; #[storage] pub struct Storage {} @@ -65,7 +65,7 @@ pub mod SettingsComponent { ); // Check game address is supported - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: get_contract_address(), }; let is_single_game = game_address == minigame_token_dispatcher.game_address(); diff --git a/packages/embeddable_game_standard/src/token/extensions/skills.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/skills.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/skills.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/skills.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/skills/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/skills/interface.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/extensions/skills/interface.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/skills/interface.cairo diff --git a/packages/embeddable_game_standard/src/token/extensions/skills/skills.cairo b/packages/embeddable_game_standard/src/token_legacy/extensions/skills/skills.cairo similarity index 93% rename from packages/embeddable_game_standard/src/token/extensions/skills/skills.cairo rename to packages/embeddable_game_standard/src/token_legacy/extensions/skills/skills.cairo index cbd14740..6596b529 100644 --- a/packages/embeddable_game_standard/src/token/extensions/skills/skills.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/extensions/skills/skills.cairo @@ -10,13 +10,13 @@ pub mod SkillsComponent { Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::token::extensions::skills::interface::{ + use crate::token_legacy::extensions::skills::interface::{ IMINIGAME_TOKEN_SKILLS_ID, IMinigameTokenSkills, }; - use crate::token::token::address_utils; - use crate::token::token_component::CoreTokenComponent; - use crate::token::token_component::CoreTokenComponent::EventEmittersTrait; - use crate::token::traits::OptionalSkills; + use crate::token_legacy::token::address_utils; + use crate::token_legacy::token_component::CoreTokenComponent; + use crate::token_legacy::token_component::CoreTokenComponent::EventEmittersTrait; + use crate::token_legacy::traits::OptionalSkills; #[storage] pub struct Storage { diff --git a/packages/embeddable_game_standard/src/token_legacy/interface.cairo b/packages/embeddable_game_standard/src/token_legacy/interface.cairo new file mode 100644 index 00000000..a022c461 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_legacy/interface.cairo @@ -0,0 +1,235 @@ +// Re-export from interfaces package +pub use game_components_interfaces::registry::{ + GameMetadata, IMINIGAME_REGISTRY_ID, IMinigameRegistry, IMinigameRegistryDispatcher, + IMinigameRegistryDispatcherTrait, +}; +pub use game_components_interfaces::structs::metagame::GameContextDetails; +pub use game_components_interfaces::structs::minigame::{GameObjective, GameSetting}; +pub use game_components_interfaces::token::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, + IMinigameTokenLegacyDispatcherTrait, +}; +use starknet::ContractAddress; +use crate::token_legacy::structs::{ + MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, +}; + +#[starknet::interface] +pub trait IMinigameTokenMixin { + // Core token functionality + fn token_metadata(self: @TState, token_id: felt252) -> TokenMetadata; + fn is_playable(self: @TState, token_id: felt252) -> bool; + fn assert_is_playable(self: @TState, token_id: felt252); + fn settings_id(self: @TState, token_id: felt252) -> u32; + fn player_name(self: @TState, token_id: felt252) -> felt252; + fn objective_id(self: @TState, token_id: felt252) -> u32; + fn minted_by(self: @TState, token_id: felt252) -> felt252; + fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; + fn game_address(self: @TState) -> ContractAddress; + fn game_registry_address(self: @TState) -> ContractAddress; + fn is_soulbound(self: @TState, token_id: felt252) -> bool; + fn renderer_address(self: @TState, token_id: felt252) -> ContractAddress; + fn token_game_address(self: @TState, token_id: felt252) -> ContractAddress; + fn token_mutable_state(self: @TState, token_id: felt252) -> TokenMutableState; + fn client_url(self: @TState, token_id: felt252) -> ByteArray; + fn skills_address(self: @TState, token_id: felt252) -> ContractAddress; + + // Batch view functions + fn token_metadata_batch(self: @TState, token_ids: Span) -> Array; + fn is_playable_batch(self: @TState, token_ids: Span) -> Array; + fn settings_id_batch(self: @TState, token_ids: Span) -> Array; + fn player_name_batch(self: @TState, token_ids: Span) -> Array; + fn objective_id_batch(self: @TState, token_ids: Span) -> Array; + fn minted_by_batch(self: @TState, token_ids: Span) -> Array; + fn minted_by_address_batch(self: @TState, token_ids: Span) -> Array; + fn is_soulbound_batch(self: @TState, token_ids: Span) -> Array; + fn renderer_address_batch(self: @TState, token_ids: Span) -> Array; + fn token_game_address_batch(self: @TState, token_ids: Span) -> Array; + fn token_mutable_state_batch( + self: @TState, token_ids: Span, + ) -> Array; + fn token_full_state_batch(self: @TState, token_ids: Span) -> Array; + + fn mint( + ref self: TState, + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> felt252; + fn update_game(ref self: TState, token_id: felt252); + fn refresh_metadata(ref self: TState, token_id: felt252); + fn update_player_name(ref self: TState, token_id: felt252, name: felt252); + + // Batch write functions + fn mint_batch_recipients( + ref self: TState, + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> Array; + fn update_game_batch(ref self: TState, token_ids: Span); + fn refresh_metadata_batch(ref self: TState, token_ids: Span); + fn update_player_name_batch(ref self: TState, updates: Span); + + // Minter functionality + fn get_minter_address(self: @TState, minter_id: u64) -> starknet::ContractAddress; + fn get_minter_id(self: @TState, minter_address: starknet::ContractAddress) -> u64; + fn minter_exists(self: @TState, minter_address: starknet::ContractAddress) -> bool; + fn total_minters(self: @TState) -> u64; + // Objective functionality + fn create_objective( + ref self: TState, + game_address: ContractAddress, + creator_address: ContractAddress, + objective_id: u32, + settings_id: u32, + objective_data: GameObjective, + ); + // Settings functionality + fn create_settings( + ref self: TState, + game_address: ContractAddress, + settings_id: u32, + name: ByteArray, + description: ByteArray, + settings_data: Span, + ); + // Renderer functionality + fn get_renderer(self: @TState, token_id: felt252) -> starknet::ContractAddress; + fn has_custom_renderer(self: @TState, token_id: felt252) -> bool; + fn reset_token_renderer(ref self: TState, token_id: felt252); + + // Renderer batch operations + fn reset_token_renderer_batch(ref self: TState, token_ids: Span); + fn get_renderer_batch( + self: @TState, token_ids: Span, + ) -> Array; + + // Skills functionality + fn get_skills_address(self: @TState, token_id: felt252) -> starknet::ContractAddress; + fn has_custom_skills(self: @TState, token_id: felt252) -> bool; + fn reset_token_skills(ref self: TState, token_id: felt252); + + // Skills batch operations + fn reset_token_skills_batch(ref self: TState, token_ids: Span); + fn get_skills_address_batch( + self: @TState, token_ids: Span, + ) -> Array; +} + +// ============================================================================== +// TOKEN EVENT RELAYER - DEPRECATED +// ============================================================================== +// This interface is deprecated. Use native Starknet events instead. +// Keeping for backwards compatibility during migration. +// Will be removed in a future version. + +#[starknet::interface] +pub trait ITokenEventRelayer { + fn initialize( + ref self: TContractState, + token_address: ContractAddress, + game_registry_address: ContractAddress, + ); + + // Core token events + fn emit_owners( + ref self: TContractState, token_id: u64, owner: ContractAddress, auth: ContractAddress, + ); + fn emit_token_metadata_update( + ref self: TContractState, + id: u64, + game_id: u64, + minted_at: u64, + settings_id: u32, + lifecycle_start: u64, + lifecycle_end: u64, + minted_by: u64, + soulbound: bool, + game_over: bool, + completed_objective: bool, + has_context: bool, + objectives_count: u8, + ); + fn emit_token_player_name_update(ref self: TContractState, id: u64, player_name: felt252); + fn emit_token_client_url_update(ref self: TContractState, id: u64, client_url: ByteArray); + fn emit_token_score_update(ref self: TContractState, id: u64, score: u64); + + // Objectives extension events + fn emit_objective_created( + ref self: TContractState, + game_address: ContractAddress, + creator_address: ContractAddress, + objective_id: u32, + objective_data: ByteArray, + ); + fn emit_objective_update( + ref self: TContractState, token_id: u64, objective_id: u32, completed: bool, + ); + + // Settings extension events + fn emit_settings_created( + ref self: TContractState, + game_address: ContractAddress, + creator_address: ContractAddress, + settings_id: u32, + settings_data: ByteArray, + ); + + // Minter extension events + fn emit_minter_registry_update( + ref self: TContractState, id: u64, minter_address: ContractAddress, + ); + + // Context extension events + fn emit_token_context_update(ref self: TContractState, id: u64, context_data: ByteArray); + + // Additional renderer events + fn emit_token_renderer_update( + ref self: TContractState, id: u64, renderer_address: ContractAddress, + ); + + // MinigameRegistry events + fn emit_game_metadata_update( + ref self: TContractState, + id: u64, + contract_address: ContractAddress, + name: ByteArray, + description: ByteArray, + developer: ByteArray, + publisher: ByteArray, + genre: ByteArray, + image: ByteArray, + color: ByteArray, + client_url: ByteArray, + renderer_address: ContractAddress, + skills_address: ContractAddress, + ); + fn emit_game_registry_update( + ref self: TContractState, id: u64, contract_address: ContractAddress, + ); +} diff --git a/packages/embeddable_game_standard/src/token/noop_traits.cairo b/packages/embeddable_game_standard/src/token_legacy/noop_traits.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/noop_traits.cairo rename to packages/embeddable_game_standard/src/token_legacy/noop_traits.cairo index 1d04adf5..c7394e44 100644 --- a/packages/embeddable_game_standard/src/token/noop_traits.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/noop_traits.cairo @@ -1,6 +1,6 @@ use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; use starknet::ContractAddress; -use crate::token::traits::{ +use crate::token_legacy::traits::{ OptionalContext, OptionalMinter, OptionalObjectives, OptionalRenderer, OptionalSettings, OptionalSkills, OptionalSoulbound, }; diff --git a/packages/embeddable_game_standard/src/token/structs.cairo b/packages/embeddable_game_standard/src/token_legacy/structs.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/structs.cairo rename to packages/embeddable_game_standard/src/token_legacy/structs.cairo diff --git a/packages/embeddable_game_standard/src/token_legacy/tests.cairo b/packages/embeddable_game_standard/src/token_legacy/tests.cairo new file mode 100644 index 00000000..1ec0be60 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_legacy/tests.cairo @@ -0,0 +1,33 @@ +// Token package tests +// Migrated from tests/src/token/ following cairo-contracts test structure + +mod examples; +mod libs; +mod mocks; +mod setup; +mod test_additional_coverage; +mod test_address_utils; +mod test_batch_views; +mod test_component_coverage; +mod test_context; +mod test_context_coverage; +mod test_core_token; +mod test_core_token_coverage; +mod test_enumerable; +mod test_events; +mod test_examples_coverage; +mod test_extensions; +mod test_full_token_contract; +mod test_fuzz; +mod test_integration; +mod test_lifecycle; +mod test_minimal_optimized; +mod test_minter; +mod test_noop_traits; +mod test_objectives; +mod test_packed_token_id; +mod test_renderer; +mod test_settings; +mod test_skills; +mod test_structs_coverage; +mod test_token_state; diff --git a/packages/embeddable_game_standard/src/token/tests/examples.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/examples.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/tests/examples.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/examples.cairo diff --git a/packages/embeddable_game_standard/src/token/tests/examples/full_token_contract.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/examples/full_token_contract.cairo similarity index 97% rename from packages/embeddable_game_standard/src/token/tests/examples/full_token_contract.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/examples/full_token_contract.cairo index eed3dd4e..5b38aaac 100644 --- a/packages/embeddable_game_standard/src/token/tests/examples/full_token_contract.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/examples/full_token_contract.cairo @@ -25,14 +25,14 @@ use openzeppelin_token::erc721::ERC721Component; use starknet::ContractAddress; use starknet::storage::StoragePointerReadAccess; use starknet::syscalls::call_contract_syscall; -use crate::token::extensions::context::context::ContextComponent; -use crate::token::extensions::minter::minter::MinterComponent; -use crate::token::extensions::objectives::objectives::ObjectivesComponent; -use crate::token::extensions::renderer::renderer::RendererComponent; -use crate::token::extensions::settings::settings::SettingsComponent; -use crate::token::extensions::skills::skills::SkillsComponent; -use crate::token::structs::TokenMetadata; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::extensions::context::context::ContextComponent; +use crate::token_legacy::extensions::minter::minter::MinterComponent; +use crate::token_legacy::extensions::objectives::objectives::ObjectivesComponent; +use crate::token_legacy::extensions::renderer::renderer::RendererComponent; +use crate::token_legacy::extensions::settings::settings::SettingsComponent; +use crate::token_legacy::extensions::skills::skills::SkillsComponent; +use crate::token_legacy::structs::TokenMetadata; +use crate::token_legacy::token_component::CoreTokenComponent; #[starknet::contract] diff --git a/packages/embeddable_game_standard/src/token/tests/examples/minigame_registry_contract.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/examples/minigame_registry_contract.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/examples/minigame_registry_contract.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/examples/minigame_registry_contract.cairo index 1b5b7f0e..c48f4d08 100644 --- a/packages/embeddable_game_standard/src/token/tests/examples/minigame_registry_contract.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/examples/minigame_registry_contract.cairo @@ -14,8 +14,10 @@ pub mod MinigameRegistryContract { Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_caller_address}; - use crate::token::interface::{ITokenEventRelayerDispatcher, ITokenEventRelayerDispatcherTrait}; - // use crate::token::extensions::multi_game::interface::{IMinigameTokenMultiGame}; + use crate::token_legacy::interface::{ + ITokenEventRelayerDispatcher, ITokenEventRelayerDispatcherTrait, + }; + // use crate::token_legacy::extensions::multi_game::interface::{IMinigameTokenMultiGame}; use super::{GameFeeInfo, GameMetadata}; use super::{IMINIGAME_REGISTRY_ID, IMinigameRegistry}; diff --git a/packages/embeddable_game_standard/src/token/tests/examples/minimal_optimized_example.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/examples/minimal_optimized_example.cairo similarity index 96% rename from packages/embeddable_game_standard/src/token/tests/examples/minimal_optimized_example.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/examples/minimal_optimized_example.cairo index 82972c84..1c1dd560 100644 --- a/packages/embeddable_game_standard/src/token/tests/examples/minimal_optimized_example.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/examples/minimal_optimized_example.cairo @@ -7,11 +7,11 @@ use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_token::common::erc2981::erc2981::{DefaultConfig, ERC2981Component}; use openzeppelin_token::erc721::ERC721Component; use starknet::ContractAddress; -use crate::token::extensions::minter::minter::MinterComponent; -use crate::token::noop_traits::{ +use crate::token_legacy::extensions::minter::minter::MinterComponent; +use crate::token_legacy::noop_traits::{ NoOpContext, NoOpObjectives, NoOpRenderer, NoOpSettings, NoOpSkills, NoOpSoulbound, }; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::token_component::CoreTokenComponent; #[starknet::contract] pub mod MinimalOptimizedContract { diff --git a/packages/embeddable_game_standard/src/token/tests/examples/single_game_token_contract.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/examples/single_game_token_contract.cairo similarity index 96% rename from packages/embeddable_game_standard/src/token/tests/examples/single_game_token_contract.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/examples/single_game_token_contract.cairo index bb52a188..1ca7eea7 100644 --- a/packages/embeddable_game_standard/src/token/tests/examples/single_game_token_contract.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/examples/single_game_token_contract.cairo @@ -19,16 +19,16 @@ use openzeppelin_token::erc721::ERC721Component; use starknet::ContractAddress; use starknet::storage::StoragePointerReadAccess; use starknet::syscalls::call_contract_syscall; -use crate::token::extensions::context::context::ContextComponent; -use crate::token::extensions::minter::minter::MinterComponent; -use crate::token::extensions::objectives::objectives::ObjectivesComponent; -use crate::token::extensions::renderer::renderer::RendererComponent; -use crate::token::extensions::settings::settings::SettingsComponent; -use crate::token::extensions::skills::skills::SkillsComponent; -use crate::token::structs::TokenMetadata; +use crate::token_legacy::extensions::context::context::ContextComponent; +use crate::token_legacy::extensions::minter::minter::MinterComponent; +use crate::token_legacy::extensions::objectives::objectives::ObjectivesComponent; +use crate::token_legacy::extensions::renderer::renderer::RendererComponent; +use crate::token_legacy::extensions::settings::settings::SettingsComponent; +use crate::token_legacy::extensions::skills::skills::SkillsComponent; +use crate::token_legacy::structs::TokenMetadata; // Game components imports - use the actual package paths -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::token_component::CoreTokenComponent; #[starknet::contract] diff --git a/packages/embeddable_game_standard/src/token/tests/libs.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/libs.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/tests/libs.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/libs.cairo diff --git a/packages/embeddable_game_standard/src/token/tests/mocks.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/mocks.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/tests/mocks.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/mocks.cairo diff --git a/packages/embeddable_game_standard/src/token/tests/setup.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/setup.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo index 65c8f350..71baa807 100644 --- a/packages/embeddable_game_standard/src/token/tests/setup.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo @@ -1,4 +1,3 @@ -use game_components_embeddable_game_standard::metagame::interface::IMetagameDispatcher; use game_components_embeddable_game_standard::minigame::interface::IMinigameDispatcher; use game_components_embeddable_game_standard::registry::interface::IMinigameRegistryDispatcher; @@ -11,7 +10,7 @@ use openzeppelin_interfaces::erc721::ERC721ABIDispatcher; use openzeppelin_interfaces::introspection::ISRC5Dispatcher; use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; use starknet::ContractAddress; -use crate::token::interface::IMinigameTokenMixinDispatcher; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcher; // Import from local mocks use super::mocks::metagame_mock::{ @@ -68,14 +67,13 @@ pub fn deploy_basic_mock_game() -> (IMinigameDispatcher, IMockGameDispatcher) { /// Deploy metagame_mock contract pub fn deploy_mock_metagame_contract() -> ( - IMetagameDispatcher, IMetagameMockInitDispatcher, IMetagameMockDispatcher, + ContractAddress, IMetagameMockInitDispatcher, IMetagameMockDispatcher, ) { let contract = declare("metagame_mock").unwrap().contract_class(); let (contract_address, _) = contract.deploy(@array![]).unwrap(); - let metagame_dispatcher = IMetagameDispatcher { contract_address }; let metagame_init_dispatcher = IMetagameMockInitDispatcher { contract_address }; let metagame_mock_dispatcher = IMetagameMockDispatcher { contract_address }; - (metagame_dispatcher, metagame_init_dispatcher, metagame_mock_dispatcher) + (contract_address, metagame_init_dispatcher, metagame_mock_dispatcher) } /// Deploy MinigameRegistryContract with default parameters @@ -124,7 +122,7 @@ pub fn deploy_mock_context_provider() -> ContractAddress { /// Deploy MockMetagameWithContext contract pub fn deploy_mock_metagame_with_context( context_address: Option, minigame_token_address: ContractAddress, -) -> IMetagameDispatcher { +) -> ContractAddress { let contract = declare("TokenMockMetagameWithContext").unwrap().contract_class(); let mut constructor_calldata = array![]; @@ -143,7 +141,7 @@ pub fn deploy_mock_metagame_with_context( constructor_calldata.append(minigame_token_address.into()); let (contract_address, _) = contract.deploy(@constructor_calldata).unwrap(); - IMetagameDispatcher { contract_address } + contract_address } /// Deploy standalone MockGame contract (returns just the address) diff --git a/packages/embeddable_game_standard/src/token/tests/test_additional_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_additional_coverage.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_additional_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_additional_coverage.cairo index f20adcd6..082be370 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_additional_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_additional_coverage.cairo @@ -14,7 +14,7 @@ use snforge_std::{ CheatSpan, EventSpyTrait, cheat_caller_address, mock_call, spy_events, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; use super::mocks::mock_game::IMockGameDispatcherTrait; use super::setup::{ diff --git a/packages/embeddable_game_standard/src/token/tests/test_address_utils.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_address_utils.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_address_utils.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_address_utils.cairo index 392e0a62..8515923b 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_address_utils.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_address_utils.cairo @@ -3,7 +3,7 @@ use core::num::traits::Zero; use starknet::ContractAddress; -use crate::token::token::address_utils::{ +use crate::token_legacy::token::address_utils::{ address_to_option, addresses_equal, assert_not_zero_address, has_non_zero_address, is_non_zero_address, is_zero_address, unwrap_or_address, unwrap_or_zero, }; diff --git a/packages/embeddable_game_standard/src/token/tests/test_batch_views.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_batch_views.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_batch_views.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_batch_views.cairo index 0b92dc5a..9df09f74 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_batch_views.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_batch_views.cairo @@ -1,5 +1,5 @@ // Tests for batch view functions in CoreTokenComponent -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::setup::{ALICE, BOB, CHARLIE, setup}; // ============================================================================ @@ -735,7 +735,7 @@ fn test_batch_matches_individual_calls() { // BATCH WRITE FUNCTION TESTS // ============================================================================ -use crate::token::structs::{MintBatchRecipient, PlayerNameUpdate}; +use crate::token_legacy::structs::{MintBatchRecipient, PlayerNameUpdate}; #[test] #[should_panic(expected: "MinigameToken: recipients array cannot be empty")] diff --git a/packages/embeddable_game_standard/src/token/tests/test_component_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_component_coverage.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/test_component_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_component_coverage.cairo index 78391cf3..338b9545 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_component_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_component_coverage.cairo @@ -12,7 +12,7 @@ use openzeppelin_interfaces::introspection::ISRC5DispatcherTrait; use snforge_std::{ CheatSpan, cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; use super::mocks::mock_game::IMockGameDispatcherTrait; use super::setup::{ @@ -368,9 +368,9 @@ fn test_erc721_balance_of_after_mint() { #[test] fn test_supports_minigame_token_interface() { let test_contracts = setup(); - use crate::token::interface::IMINIGAME_TOKEN_ID; - let supports = test_contracts.src5.supports_interface(IMINIGAME_TOKEN_ID); - assert!(supports, "Should support IMinigameToken interface"); + use crate::token_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; + let supports = test_contracts.src5.supports_interface(IMINIGAME_TOKEN_LEGACY_ID); + assert!(supports, "Should support IMinigameTokenLegacy interface"); } // ============================================================================ diff --git a/packages/embeddable_game_standard/src/token/tests/test_context.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo similarity index 94% rename from packages/embeddable_game_standard/src/token/tests/test_context.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo index 531e4ca0..150bb5e0 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_context.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo @@ -4,8 +4,8 @@ use openzeppelin_interfaces::introspection::ISRC5DispatcherTrait; use snforge_std::{EventSpyTrait, spy_events}; use starknet::ContractAddress; -use crate::token::extensions::context::interface::IMINIGAME_TOKEN_CONTEXT_ID; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::extensions::context::interface::IMINIGAME_TOKEN_CONTEXT_ID; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::metagame_mock::IMetagameMockDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; @@ -31,7 +31,7 @@ fn test_context_through_metagame_mint() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('ContextPlayer'), Option::None, Option::None, @@ -103,7 +103,7 @@ fn test_context_with_lifecycle_combination() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('TimedContextPlayer'), Option::None, Option::Some(start_time), @@ -135,7 +135,7 @@ fn test_context_with_soulbound_combination() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('SoulboundContextPlayer'), Option::None, Option::None, @@ -167,7 +167,7 @@ fn test_context_with_renderer_combination() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('RendererContextPlayer'), Option::None, Option::None, @@ -205,7 +205,7 @@ fn test_batch_mint_with_context() { let token_id1 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player1'), Option::None, Option::None, @@ -224,7 +224,7 @@ fn test_batch_mint_with_context() { let token_id2 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player2'), Option::None, Option::None, @@ -285,7 +285,7 @@ fn test_context_with_empty_player_name() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::None, // No player name Option::None, Option::None, @@ -318,7 +318,7 @@ fn test_context_with_all_parameters() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('FullPlayer'), Option::None, // settings Option::Some(1000), // start @@ -381,7 +381,7 @@ fn test_has_context_flag_in_metadata() { let token_id2 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::None, Option::None, Option::None, @@ -418,7 +418,7 @@ fn test_event_spy_captures_context_events() { let _token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('EventTestPlayer'), Option::None, Option::None, @@ -459,7 +459,7 @@ fn test_fuzz_context_with_various_token_ids(mint_count: u8) { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('FuzzPlayer'), Option::None, Option::None, @@ -496,7 +496,7 @@ fn test_context_with_registry_game() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('RegistryPlayer'), Option::None, Option::None, @@ -531,7 +531,7 @@ fn test_multiple_context_mints_unique_data() { let token_id1 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Alice'), Option::None, Option::Some(100), @@ -551,7 +551,7 @@ fn test_multiple_context_mints_unique_data() { let token_id2 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Bob'), Option::None, Option::Some(300), diff --git a/packages/embeddable_game_standard/src/token/tests/test_context_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo similarity index 93% rename from packages/embeddable_game_standard/src/token/tests/test_context_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo index 2459914d..c160eaa7 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_context_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo @@ -6,7 +6,7 @@ fn addr(value: felt252) -> ContractAddress { value.try_into().unwrap() } use snforge_std::{EventSpyTrait, spy_events}; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::metagame_mock::IMetagameMockDispatcherTrait; use super::setup::{ALICE, BOB, setup}; @@ -49,7 +49,7 @@ fn test_context_through_metagame() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('ContextualPlayer'), Option::None, // No settings_id Option::Some(1000), // start @@ -90,7 +90,7 @@ fn test_multiple_context_mints() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some(name.clone()), Option::None, Option::None, @@ -131,7 +131,7 @@ fn test_context_with_game_lifecycle() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('TimedPlayer'), Option::None, Option::Some(start_time), @@ -162,7 +162,7 @@ fn test_context_with_soulbound() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('SoulboundPlayer'), Option::None, Option::None, @@ -193,7 +193,7 @@ fn test_context_extension_edge_cases() { let token_id1 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::None, // No player name Option::None, Option::None, @@ -213,7 +213,7 @@ fn test_context_extension_edge_cases() { let token_id2 = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('FullPlayer'), Option::None, // No settings Option::Some(1000), // start diff --git a/packages/embeddable_game_standard/src/token/tests/test_core_token.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_core_token.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_core_token.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_core_token.cairo index 906ca8e4..a56894b5 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_core_token.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_core_token.cairo @@ -9,9 +9,9 @@ use snforge_std::{ start_cheat_block_timestamp, stop_cheat_block_timestamp, }; use starknet::ContractAddress; -use crate::token::interface::{IMINIGAME_TOKEN_ID, IMinigameTokenMixinDispatcherTrait}; -use crate::token::structs::{MintBatchRecipient, PlayerNameUpdate}; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::interface::{IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenMixinDispatcherTrait}; +use crate::token_legacy::structs::{MintBatchRecipient, PlayerNameUpdate}; +use crate::token_legacy::token_component::CoreTokenComponent; use super::mocks::mock_game::IMockGameDispatcherTrait; use super::setup::{ ALICE, BOB, CHARLIE, CURRENT_TIME, FAR_FUTURE_TIME, FUTURE_TIME, OWNER, PAST_TIME, @@ -1858,8 +1858,8 @@ fn test_initializer_src5_interface_registered() { // TC-I-007: SRC5 interface registered let test_contracts = setup(); - let supports = test_contracts.src5.supports_interface(IMINIGAME_TOKEN_ID); - assert!(supports, "Should support IMINIGAME_TOKEN_ID interface"); + let supports = test_contracts.src5.supports_interface(IMINIGAME_TOKEN_LEGACY_ID); + assert!(supports, "Should support IMINIGAME_TOKEN_LEGACY_ID interface"); } // ============================================================================ diff --git a/packages/embeddable_game_standard/src/token/tests/test_core_token_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_core_token_coverage.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_core_token_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_core_token_coverage.cairo index 1b8d6a1f..08a116b1 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_core_token_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_core_token_coverage.cairo @@ -11,8 +11,8 @@ use openzeppelin_interfaces::erc721::ERC721ABIDispatcherTrait; use snforge_std::{ CheatSpan, cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; -use crate::token::structs::PlayerNameUpdate; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::structs::PlayerNameUpdate; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; use super::mocks::mock_game::IMockGameDispatcherTrait; use super::setup::{ diff --git a/packages/embeddable_game_standard/src/token/tests/test_enumerable.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_enumerable.cairo similarity index 97% rename from packages/embeddable_game_standard/src/token/tests/test_enumerable.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_enumerable.cairo index c3fe1e6c..b0bb4c92 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_enumerable.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_enumerable.cairo @@ -1,6 +1,6 @@ use EnumerableComponent::{EnumerableImpl, InternalImpl}; -use game_components_embeddable_game_standard::token::extensions::enumerable::enumerable::EnumerableComponent; -use game_components_embeddable_game_standard::token::extensions::enumerable::interface::IENUMERABLE_OWNER_ID; +use game_components_embeddable_game_standard::token_legacy::extensions::enumerable::enumerable::EnumerableComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::enumerable::interface::IENUMERABLE_OWNER_ID; use game_components_test_common::mocks::mock_enumerable::EnumerableMock; use openzeppelin_interfaces::introspection::ISRC5_ID; use openzeppelin_introspection::src5::SRC5Component::SRC5Impl; diff --git a/packages/embeddable_game_standard/src/token/tests/test_events.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_events.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/test_events.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_events.cairo index d23b7330..27fc07b9 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_events.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_events.cairo @@ -3,8 +3,10 @@ use snforge_std::{ CheatSpan, EventSpyAssertionsTrait, EventSpyTrait, cheat_caller_address, spy_events, }; use starknet::ContractAddress; -use crate::token::interface::{IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait}; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::interface::{ + IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait, +}; +use crate::token_legacy::token_component::CoreTokenComponent; use super::mocks::minigame_mock::IMinigameMockInitDispatcherTrait; // Import IMockGameDispatcher trait diff --git a/packages/embeddable_game_standard/src/token/tests/test_examples_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/test_examples_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo index 18879229..ebb8da47 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_examples_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo @@ -8,7 +8,7 @@ fn addr(value: felt252) -> ContractAddress { use snforge_std::{ CheatSpan, cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::metagame_mock::IMetagameMockDispatcherTrait; use super::mocks::mock_game::IMockGameDispatcherTrait; use super::setup::{ @@ -100,7 +100,7 @@ fn test_optimized_contract_context_operations() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('ContextPlayer'), Option::None, // settings_id Option::None, // start diff --git a/packages/embeddable_game_standard/src/token/tests/test_extensions.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_extensions.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/test_extensions.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_extensions.cairo index 607a8ba5..c1782871 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_extensions.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_extensions.cairo @@ -5,7 +5,7 @@ fn addr(value: felt252) -> ContractAddress { value.try_into().unwrap() } use snforge_std::spy_events; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; // Import setup helpers use super::setup::{ALICE, BOB, OWNER, deploy_basic_mock_game, deploy_single_game_token_contract}; @@ -14,7 +14,7 @@ use super::setup::{ALICE, BOB, OWNER, deploy_basic_mock_game, deploy_single_game /// address. fn deploy_single_game_token_custom_metadata( name: ByteArray, symbol: ByteArray, base_uri: ByteArray, -) -> (crate::token::interface::IMinigameTokenMixinDispatcher, ContractAddress) { +) -> (crate::token_legacy::interface::IMinigameTokenMixinDispatcher, ContractAddress) { let (minigame, _) = deploy_basic_mock_game(); let (token_dispatcher, _, _, _) = deploy_single_game_token_contract( Option::Some(name), diff --git a/packages/embeddable_game_standard/src/token/tests/test_full_token_contract.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_full_token_contract.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_full_token_contract.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_full_token_contract.cairo index 24015284..3aacd5bd 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_full_token_contract.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_full_token_contract.cairo @@ -12,7 +12,7 @@ use snforge_std::{ CheatSpan, cheat_caller_address, mock_call, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; // Import mocks diff --git a/packages/embeddable_game_standard/src/token/tests/test_fuzz.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_fuzz.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_fuzz.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_fuzz.cairo index 3a6823ce..6ab7ebb2 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_fuzz.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_fuzz.cairo @@ -9,7 +9,7 @@ use game_components_embeddable_game_standard::minigame::interface::{ }; use openzeppelin_interfaces::erc721::ERC721ABIDispatcherTrait; use snforge_std::{mock_call, start_cheat_block_timestamp, stop_cheat_block_timestamp}; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; // Import setup helpers use super::setup::{setup}; diff --git a/packages/embeddable_game_standard/src/token/tests/test_integration.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/tests/test_integration.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo index b93d9c37..e50428de 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_integration.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo @@ -4,7 +4,7 @@ use snforge_std::{ CheatSpan, cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; use starknet::ContractAddress; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::metagame_mock::{ IMetagameCallbackMockViewDispatcherTrait, IMetagameMockDispatcherTrait, }; @@ -235,8 +235,6 @@ mod TokenMockMetagameWithContext { component!(path: MetagameComponent, storage: metagame, event: MetagameEvent); component!(path: SRC5Component, storage: src5, event: SRC5Event); - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; #[abi(embed_v0)] @@ -265,9 +263,7 @@ mod TokenMockMetagameWithContext { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address, minigame_token_address); - } + ) {} } // ================================================================================================ @@ -626,7 +622,7 @@ fn test_update_game_triggers_game_action_callback() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player1'), Option::None, Option::None, @@ -668,7 +664,7 @@ fn test_update_game_triggers_game_over_callback() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player2'), Option::None, Option::None, @@ -707,7 +703,7 @@ fn test_update_game_no_game_over_callback_without_transition() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player3'), Option::None, Option::None, @@ -750,7 +746,7 @@ fn test_update_game_triggers_objective_complete_callback() { let token_id = test_contracts .metagame_mock .mint_game( - Option::Some(test_contracts.minigame.contract_address), + test_contracts.minigame.contract_address, Option::Some('Player4'), Option::None, Option::None, diff --git a/packages/embeddable_game_standard/src/token/tests/test_lifecycle.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_lifecycle.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_lifecycle.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_lifecycle.cairo index ac248e19..6361bb99 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_lifecycle.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_lifecycle.cairo @@ -1,6 +1,6 @@ use core::num::traits::Bounded; -use crate::token::structs::Lifecycle; -use crate::token::token::LifecycleTrait; +use crate::token_legacy::structs::Lifecycle; +use crate::token_legacy::token::LifecycleTrait; // ================================================================================================ // LIFECYCLE TESTS (UT-LIFE-*) diff --git a/packages/embeddable_game_standard/src/token/tests/test_minimal_optimized.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_minimal_optimized.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_minimal_optimized.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_minimal_optimized.cairo index 508383be..b913dda0 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_minimal_optimized.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_minimal_optimized.cairo @@ -8,8 +8,10 @@ use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTr use snforge_std::{ CheatSpan, cheat_caller_address, start_cheat_block_timestamp, stop_cheat_block_timestamp, }; -use crate::token::interface::{IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait}; -use crate::token::structs::PlayerNameUpdate; +use crate::token_legacy::interface::{ + IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait, +}; +use crate::token_legacy::structs::PlayerNameUpdate; use super::mocks::mock_game::IMockGameDispatcherTrait; // Import setup helpers diff --git a/packages/embeddable_game_standard/src/token/tests/test_minter.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_minter.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_minter.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_minter.cairo index 5dfe1eb8..8521f9bb 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_minter.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_minter.cairo @@ -7,9 +7,9 @@ use snforge_std::{ CheatSpan, EventSpyAssertionsTrait, EventSpyTrait, cheat_caller_address, spy_events, }; use starknet::ContractAddress; -use crate::token::extensions::minter::interface::IMINIGAME_TOKEN_MINTER_ID; -use crate::token::extensions::minter::minter::MinterComponent; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::extensions::minter::interface::IMINIGAME_TOKEN_MINTER_ID; +use crate::token_legacy::extensions::minter::minter::MinterComponent; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; // Import setup helpers use super::setup::{ diff --git a/packages/embeddable_game_standard/src/token/tests/test_noop_traits.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_noop_traits.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_noop_traits.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_noop_traits.cairo index 0c77a09a..94160aec 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_noop_traits.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_noop_traits.cairo @@ -23,7 +23,9 @@ use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use snforge_std::{CheatSpan, cheat_caller_address}; use starknet::ContractAddress; -use crate::token::interface::{IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait}; +use crate::token_legacy::interface::{ + IMinigameTokenMixinDispatcher, IMinigameTokenMixinDispatcherTrait, +}; // Import setup helpers use super::setup::{ALICE, BOB, deploy_basic_mock_game, deploy_minimal_optimized_contract}; diff --git a/packages/embeddable_game_standard/src/token/tests/test_objectives.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_objectives.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_objectives.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_objectives.cairo index 3b454407..4002325d 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_objectives.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_objectives.cairo @@ -4,8 +4,8 @@ use game_components_interfaces::{IMinigameObjectivesDispatcher, IMinigameObjectivesDispatcherTrait}; use openzeppelin_interfaces::introspection::ISRC5DispatcherTrait; use snforge_std::{EventSpyTrait, spy_events}; -use crate::token::extensions::objectives::interface::IMINIGAME_TOKEN_OBJECTIVES_ID; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::extensions::objectives::interface::IMINIGAME_TOKEN_OBJECTIVES_ID; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; // Import setup helpers diff --git a/packages/embeddable_game_standard/src/token/tests/test_packed_token_id.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_packed_token_id.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_packed_token_id.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_packed_token_id.cairo index ee24446b..4d55a7cc 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_packed_token_id.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_packed_token_id.cairo @@ -4,7 +4,7 @@ // Tests for the bit-packing functions that encode/decode immutable token // metadata into the token_id (felt252). -use crate::token::structs::{ +use crate::token_legacy::structs::{ extract_tx_hash_bits, pack_token_id, unpack_end_delay, unpack_game_id, unpack_has_context, unpack_metadata, unpack_minted_at, unpack_minted_by, unpack_objective_id, unpack_paymaster, unpack_salt, unpack_settings_id, unpack_soulbound, unpack_start_delay, unpack_token_id, diff --git a/packages/embeddable_game_standard/src/token/tests/test_renderer.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_renderer.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_renderer.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_renderer.cairo index d5b2a3e9..cc28cd34 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_renderer.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_renderer.cairo @@ -7,9 +7,9 @@ use snforge_std::{ EventSpyAssertionsTrait, spy_events, start_cheat_caller_address, stop_cheat_caller_address, }; use starknet::ContractAddress; -use crate::token::extensions::renderer::interface::IMINIGAME_TOKEN_RENDERER_ID; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::extensions::renderer::interface::IMINIGAME_TOKEN_RENDERER_ID; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::token_component::CoreTokenComponent; // Import setup helpers use super::setup::{ALICE, BOB, RENDERER_ADDRESS, ZERO_ADDRESS, setup_multi_game}; diff --git a/packages/embeddable_game_standard/src/token/tests/test_settings.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_settings.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_settings.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_settings.cairo index d13c928d..4fca9d6e 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_settings.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_settings.cairo @@ -4,8 +4,8 @@ use game_components_interfaces::{IMinigameSettingsDispatcher, IMinigameSettingsDispatcherTrait}; use openzeppelin_interfaces::introspection::ISRC5DispatcherTrait; use snforge_std::{EventSpyTrait, mock_call, spy_events}; -use crate::token::extensions::settings::interface::IMINIGAME_TOKEN_SETTINGS_ID; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::extensions::settings::interface::IMINIGAME_TOKEN_SETTINGS_ID; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; use super::mocks::minigame_mock::IMinigameMockDispatcherTrait; // Import setup helpers diff --git a/packages/embeddable_game_standard/src/token/tests/test_skills.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_skills.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_skills.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_skills.cairo index 09cebcd3..d99ee82d 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_skills.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_skills.cairo @@ -7,9 +7,9 @@ use snforge_std::{ EventSpyAssertionsTrait, spy_events, start_cheat_caller_address, stop_cheat_caller_address, }; use starknet::ContractAddress; -use crate::token::extensions::skills::interface::IMINIGAME_TOKEN_SKILLS_ID; -use crate::token::interface::IMinigameTokenMixinDispatcherTrait; -use crate::token::token_component::CoreTokenComponent; +use crate::token_legacy::extensions::skills::interface::IMINIGAME_TOKEN_SKILLS_ID; +use crate::token_legacy::interface::IMinigameTokenMixinDispatcherTrait; +use crate::token_legacy::token_component::CoreTokenComponent; // Import setup helpers use super::setup::{ALICE, BOB, setup_multi_game}; diff --git a/packages/embeddable_game_standard/src/token/tests/test_structs_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_structs_coverage.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_structs_coverage.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_structs_coverage.cairo index 3d8d5674..884f64e5 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_structs_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_structs_coverage.cairo @@ -8,7 +8,7 @@ // - to_token_metadata helper function use game_components_interfaces::structs::token::Lifecycle; -use crate::token::structs::{ +use crate::token_legacy::structs::{ LifecycleStorePacking, PackedTokenId, TokenMutableState, TokenMutableStateStorePacking, extract_tx_hash_bits, pack_token_id, to_token_metadata, unpack_end_delay, unpack_game_id, unpack_has_context, unpack_metadata, unpack_minted_at, unpack_minted_by, unpack_objective_id, diff --git a/packages/embeddable_game_standard/src/token/tests/test_token_state.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_token_state.cairo similarity index 99% rename from packages/embeddable_game_standard/src/token/tests/test_token_state.cairo rename to packages/embeddable_game_standard/src/token_legacy/tests/test_token_state.cairo index e854e26a..4b7aa284 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_token_state.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_token_state.cairo @@ -2,9 +2,9 @@ // Tests pure token state management functions use core::num::traits::Bounded; -use crate::token::structs::{Lifecycle, TokenMetadata}; -use crate::token::token::LifecycleTrait; -use crate::token::token::token_state::{ +use crate::token_legacy::structs::{Lifecycle, TokenMetadata}; +use crate::token_legacy::token::LifecycleTrait; +use crate::token_legacy::token::token_state::{ create_game_token_metadata, create_lifecycle_with_defaults, ensure_game_over_transition, ensure_objectives_completion_transition, is_multi_game_token, is_single_game_token, is_token_playable, diff --git a/packages/embeddable_game_standard/src/token/token.cairo b/packages/embeddable_game_standard/src/token_legacy/token.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/token.cairo rename to packages/embeddable_game_standard/src/token_legacy/token.cairo diff --git a/packages/embeddable_game_standard/src/token/token/address_utils.cairo b/packages/embeddable_game_standard/src/token_legacy/token/address_utils.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/token/address_utils.cairo rename to packages/embeddable_game_standard/src/token_legacy/token/address_utils.cairo diff --git a/packages/embeddable_game_standard/src/token/token/lifecycle.cairo b/packages/embeddable_game_standard/src/token_legacy/token/lifecycle.cairo similarity index 97% rename from packages/embeddable_game_standard/src/token/token/lifecycle.cairo rename to packages/embeddable_game_standard/src/token_legacy/token/lifecycle.cairo index ae3de00f..9819d04b 100644 --- a/packages/embeddable_game_standard/src/token/token/lifecycle.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/token/lifecycle.cairo @@ -1,7 +1,7 @@ // Pure Cairo library for lifecycle management // Contains logic for validating and checking lifecycle constraints -use crate::token::structs::Lifecycle; +use crate::token_legacy::structs::Lifecycle; pub trait LifecycleTrait { fn has_expired(self: @Lifecycle, current_time: u64) -> bool; diff --git a/packages/embeddable_game_standard/src/token/token/token_state.cairo b/packages/embeddable_game_standard/src/token_legacy/token/token_state.cairo similarity index 97% rename from packages/embeddable_game_standard/src/token/token/token_state.cairo rename to packages/embeddable_game_standard/src/token_legacy/token/token_state.cairo index df29896f..f4cab4c3 100644 --- a/packages/embeddable_game_standard/src/token/token/token_state.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/token/token_state.cairo @@ -1,8 +1,8 @@ // Pure Cairo library for token state management // Contains logic for token playability, metadata creation, and state transitions -use crate::token::structs::{Lifecycle, TokenMetadata}; -use crate::token::token::LifecycleTrait; +use crate::token_legacy::structs::{Lifecycle, TokenMetadata}; +use crate::token_legacy::token::LifecycleTrait; /// Checks if a token is playable based on its lifecycle, game state, and objectives /// diff --git a/packages/embeddable_game_standard/src/token/token_component.cairo b/packages/embeddable_game_standard/src/token_legacy/token_component.cairo similarity index 98% rename from packages/embeddable_game_standard/src/token/token_component.cairo rename to packages/embeddable_game_standard/src/token_legacy/token_component.cairo index ca015007..ac3a342f 100644 --- a/packages/embeddable_game_standard/src/token/token_component.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/token_component.cairo @@ -23,18 +23,18 @@ pub mod CoreTokenComponent { }; use starknet::syscalls::call_contract_syscall; use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; - use crate::token::interface::{ - IMINIGAME_TOKEN_ID, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, - IMinigameToken, + use crate::token_legacy::interface::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, + IMinigameTokenLegacy, }; - use crate::token::structs::{ + use crate::token_legacy::structs::{ LifecycleStorePacking, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, TokenMutableStateStorePacking, extract_tx_hash_bits, pack_token_id, to_token_metadata, unpack_game_id, unpack_minted_by, unpack_objective_id, unpack_soulbound, unpack_token_id, }; - use crate::token::token::{LifecycleTrait, token_state}; - use crate::token::traits::{ + use crate::token_legacy::token::{LifecycleTrait, token_state}; + use crate::token_legacy::traits::{ OptionalContext, OptionalMinter, OptionalObjectives, OptionalRenderer, OptionalSettings, OptionalSkills, }; @@ -81,7 +81,7 @@ pub mod CoreTokenComponent { +Drop, +ERC721Component::ERC721HooksTrait, +ERC2981Component::ImmutableConfig, - > of IMinigameToken> { + > of IMinigameTokenLegacy> { fn token_metadata( self: @ComponentState, token_id: felt252, ) -> TokenMetadata { @@ -101,7 +101,7 @@ pub mod CoreTokenComponent { } fn settings_id(self: @ComponentState, token_id: felt252) -> u32 { - crate::token::structs::unpack_settings_id(token_id) + crate::token_legacy::structs::unpack_settings_id(token_id) } fn player_name(self: @ComponentState, token_id: felt252) -> felt252 { @@ -266,7 +266,7 @@ pub mod CoreTokenComponent { if i >= token_ids.len() { break; } - results.append(crate::token::structs::unpack_settings_id(*token_ids.at(i))); + results.append(crate::token_legacy::structs::unpack_settings_id(*token_ids.at(i))); i += 1; } results @@ -915,7 +915,7 @@ pub mod CoreTokenComponent { // Register token interface let mut contract = self.get_contract_mut(); let mut src5_component = SRC5::get_component_mut(ref contract); - src5_component.register_interface(IMINIGAME_TOKEN_ID); + src5_component.register_interface(IMINIGAME_TOKEN_LEGACY_ID); // Set game address if provided if let Option::Some(game_address) = game_address { diff --git a/packages/embeddable_game_standard/src/token/traits.cairo b/packages/embeddable_game_standard/src/token_legacy/traits.cairo similarity index 100% rename from packages/embeddable_game_standard/src/token/traits.cairo rename to packages/embeddable_game_standard/src/token_legacy/traits.cairo diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index bc86a7fe..be0a0236 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -8,7 +8,9 @@ Single source of truth for all game component interface definitions. Other packa |--------|------------|---------| | `metagame` | `IMetagame`, `IMetagameContext`, `IMetagameCallback` | Game management, context extensions | | `minigame` | `IMinigame`, `IMinigameTokenData`, `IMinigameSettings`, `IMinigameObjectives` | Game logic, score/game_over queries | -| `token` | `IMinigameToken`, `IMinigameTokenMinter`, `IMinigameTokenObjectives`, `IMinigameTokenSettings`, `IMinigameTokenRenderer` | ERC721 token with extensions | +| `token` (`token/core`) | `IMinigameToken` | THE minigame token standard: gas-optimized token embedded in the game contract itself (self-bound, no registry, no mutable state), plus the `IMinigameTokenMinter` surface | +| `token/game_fee` | `IMinigameTokenGameFee` | Game fee recipient (payout sink) + license + fee rate on the standard token (replaces the registry's `game_fee_info`); setters gated on the game contract's Ownable owner | +| `token/legacy` | `IMinigameTokenLegacy`, `IMinigameTokenMinter`, `IMinigameTokenObjectives`, `IMinigameTokenSettings`, `IMinigameTokenRenderer` | Original multi-game ERC721 token with extensions (kept for deployed denshokan) | | `registry` | `IMinigameRegistry` | Game registration and metadata lookup | | `leaderboard` | `ILeaderboard`, `ILeaderboardAdmin`, `IGameDetails` | Tournament scoring and rankings | | `tokenomics/buyback` | `IBuyback`, `IBuybackAdmin` | Autonomous buyback via Ekubo TWAMM | @@ -27,13 +29,14 @@ Single source of truth for all game component interface definitions. Other packa ## Interface ID Constants ```cairo -pub const IMETAGAME_ID: felt252 = 0x...; pub const IMETAGAME_CONTEXT_ID: felt252 = 0x...; pub const IMINIGAME_ID: felt252 = 0x...; pub const IMINIGAME_SETTINGS_ID: felt252 = 0x...; pub const IMINIGAME_OBJECTIVES_ID: felt252 = 0x...; pub const IMINIGAME_TOKEN_ID: felt252 = 0x...; +pub const IMINIGAME_TOKEN_LEGACY_ID: felt252 = 0x...; pub const IMINIGAME_TOKEN_MINTER_ID: felt252 = 0x...; +pub const IMINIGAME_TOKEN_GAME_FEE_ID: felt252 = 0x...; pub const IMINIGAME_REGISTRY_ID: felt252 = 0x...; pub const ILEADERBOARD_ID: felt252 = 0x...; ``` @@ -160,23 +163,37 @@ The tool outputs the extended function selectors and the final XOR'd interface I - For single-function interfaces, the ID equals the single extended function selector - Always update the EFS comment above the constant to match the tool's output -### Methods excluded from `IMINIGAME_TOKEN_ID` +### Methods excluded from `IMINIGAME_TOKEN_LEGACY_ID` -`IMinigameToken::refresh_metadata` and `refresh_metadata_batch` are **not** part of -the `IMINIGAME_TOKEN_ID` derivation. Omit both from the stripped input file or the -constant will not reproduce. +`IMinigameTokenLegacy::refresh_metadata` and `refresh_metadata_batch` are **not** +part of the `IMINIGAME_TOKEN_LEGACY_ID` derivation. Omit both from the stripped +input file or the constant will not reproduce. The ID is registered on-chain by every deployed token contract. Rederiving it to -cover two additive, optional methods would make `supports_interface(IMINIGAME_TOKEN_ID)` -return false on all of them and break interface discovery for every existing -consumer — a breaking change across the ecosystem in exchange for nothing a caller -can act on. The ID identifies the original surface, which those contracts all still -implement in full. +cover two additive, optional methods would make +`supports_interface(IMINIGAME_TOKEN_LEGACY_ID)` return false on all of them and +break interface discovery for every existing consumer — a breaking change across +the ecosystem in exchange for nothing a caller can act on. The ID identifies the +original surface, which those contracts all still implement in full. Apply the same reasoning to future additive methods: extend the trait, leave the ID alone, and note the exclusion here. Change the ID only for a genuinely breaking change to the existing surface. +The same refresh exclusion applies to `IMINIGAME_TOKEN_ID` (the standard token): +it is derived over `IMinigameToken` minus `refresh_metadata` (the per-selector +breakdown is kept in the doc comment above the constant in `token/core.cairo`). + +### Frozen ID values across the standard/legacy rename + +Both token interface-id VALUES are frozen — deployed contracts register them +on-chain. When the lite token became the standard, only the NAMES moved: + +| Constant (today) | Value | Was named | +| --- | --- | --- | +| `IMINIGAME_TOKEN_ID` | `0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea` | `IMINIGAME_TOKEN_LITE_ID` | +| `IMINIGAME_TOKEN_LEGACY_ID` | `0x246f614bd76b91c378a91877851f2ccdb99278e9fb77c782a22355059ce9906` | `IMINIGAME_TOKEN_ID` | + ## Dependencies None - this is a leaf package with no internal dependencies. Uses only: diff --git a/packages/interfaces/src/README.md b/packages/interfaces/src/README.md index 19661472..846276ca 100644 --- a/packages/interfaces/src/README.md +++ b/packages/interfaces/src/README.md @@ -8,7 +8,8 @@ Centralized interface and struct definitions for all game components. Other pack |--------|------------|---------| | `metagame` | `IMetagame`, `IMetagameContext`, `IMetagameCallback` | Game management, context extensions | | `minigame` | `IMinigame`, `IMinigameTokenData`, `IMinigameSettings`, `IMinigameObjectives` | Game logic, score/game_over queries | -| `token` | `IMinigameToken`, `IMinigameTokenMinter`, `IMinigameTokenObjectives`, `IMinigameTokenSettings`, `IMinigameTokenRenderer` | ERC721 token with extensions | +| `token` (`token/core`) | `IMinigameToken` | THE minigame token standard: self-bound token embedded in the game contract (plus the `IMinigameTokenMinter` surface) | +| `token/legacy` | `IMinigameTokenLegacy`, `IMinigameTokenMinter`, `IMinigameTokenObjectives`, `IMinigameTokenSettings`, `IMinigameTokenRenderer` | Original multi-game ERC721 token with extensions (kept for deployed denshokan) | | `registry` | `IMinigameRegistry` | Game registration and metadata lookup | | `leaderboard` | `ILeaderboard`, `ILeaderboardAdmin`, `IGameDetails` | Tournament scoring and rankings | | `tokenomics/buyback` | `IBuyback`, `IBuybackAdmin` | Autonomous buyback via Ekubo TWAMM | @@ -27,12 +28,12 @@ Centralized interface and struct definitions for all game components. Other pack ## Interface ID Constants ```cairo -pub const IMETAGAME_ID: felt252 = 0x...; pub const IMETAGAME_CONTEXT_ID: felt252 = 0x...; pub const IMINIGAME_ID: felt252 = 0x...; pub const IMINIGAME_SETTINGS_ID: felt252 = 0x...; pub const IMINIGAME_OBJECTIVES_ID: felt252 = 0x...; pub const IMINIGAME_TOKEN_ID: felt252 = 0x...; +pub const IMINIGAME_TOKEN_LEGACY_ID: felt252 = 0x...; pub const IMINIGAME_TOKEN_MINTER_ID: felt252 = 0x...; pub const IMINIGAME_REGISTRY_ID: felt252 = 0x...; pub const ILEADERBOARD_ID: felt252 = 0x...; diff --git a/packages/interfaces/src/lib.cairo b/packages/interfaces/src/lib.cairo index 6fc60292..1d3a76b1 100644 --- a/packages/interfaces/src/lib.cairo +++ b/packages/interfaces/src/lib.cairo @@ -55,12 +55,11 @@ pub use leaderboard::{ // Re-export commonly used items at top level for convenience // Metagame pub use metagame::{ - IMETAGAME_CALLBACK_ID, IMETAGAME_CONTEXT_ID, IMETAGAME_ID, IMetagame, IMetagameCallback, - IMetagameCallbackDispatcher, IMetagameCallbackDispatcherTrait, IMetagameContext, - IMetagameContextDetails, IMetagameContextDetailsDispatcher, - IMetagameContextDetailsDispatcherTrait, IMetagameContextDispatcher, - IMetagameContextDispatcherTrait, IMetagameContextSVG, IMetagameContextSVGDispatcher, - IMetagameContextSVGDispatcherTrait, IMetagameDispatcher, IMetagameDispatcherTrait, + IMETAGAME_CALLBACK_ID, IMETAGAME_CONTEXT_ID, IMetagameCallback, IMetagameCallbackDispatcher, + IMetagameCallbackDispatcherTrait, IMetagameContext, IMetagameContextDetails, + IMetagameContextDetailsDispatcher, IMetagameContextDetailsDispatcherTrait, + IMetagameContextDispatcher, IMetagameContextDispatcherTrait, IMetagameContextSVG, + IMetagameContextSVGDispatcher, IMetagameContextSVGDispatcherTrait, }; // Minigame @@ -99,17 +98,20 @@ pub use registry::{ // Structs pub use structs::{ - GameContext, GameContextDetails, GameDetail, GameFeeInfo, GameMetadata, GameObjective, - GameObjectiveDetails, GameSetting, GameSettingDetails, LeaderboardConfig, LeaderboardEntry, - LeaderboardResult, LeaderboardStoreConfig, Lifecycle, MintBatchRecipient, MintGameParams, - MintParams, PlayerNameUpdate, TokenMetadata, + GameContext, GameContextDetails, GameDetail, GameFeeInfo, GameFeeTerms, GameMetadata, + GameObjective, GameObjectiveDetails, GameSetting, GameSettingDetails, LeaderboardConfig, + LeaderboardEntry, LeaderboardResult, LeaderboardStoreConfig, Lifecycle, MintBatchRecipient, + MintGameParams, MintParams, PlayerNameUpdate, TokenMetadata, }; // Token pub use token::{ - IMINIGAME_TOKEN_CONTEXT_ID, IMINIGAME_TOKEN_ID, IMINIGAME_TOKEN_MINTER_ID, - IMINIGAME_TOKEN_OBJECTIVES_ID, IMINIGAME_TOKEN_RENDERER_ID, IMINIGAME_TOKEN_SETTINGS_ID, - IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, IMinigameTokenMinter, + IMINIGAME_TOKEN_CONTEXT_ID, IMINIGAME_TOKEN_GAME_FEE_ID, IMINIGAME_TOKEN_ID, + IMINIGAME_TOKEN_LEGACY_ID, IMINIGAME_TOKEN_MINTER_ID, IMINIGAME_TOKEN_OBJECTIVES_ID, + IMINIGAME_TOKEN_RENDERER_ID, IMINIGAME_TOKEN_SETTINGS_ID, IMinigameToken, + IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, IMinigameTokenGameFee, + IMinigameTokenGameFeeDispatcher, IMinigameTokenGameFeeDispatcherTrait, IMinigameTokenLegacy, + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, IMinigameTokenObjectives, IMinigameTokenObjectivesDispatcher, IMinigameTokenObjectivesDispatcherTrait, IMinigameTokenRenderer, IMinigameTokenRendererDispatcher, IMinigameTokenRendererDispatcherTrait, diff --git a/packages/interfaces/src/metagame.cairo b/packages/interfaces/src/metagame.cairo index ac05d908..baebdd42 100644 --- a/packages/interfaces/src/metagame.cairo +++ b/packages/interfaces/src/metagame.cairo @@ -14,5 +14,3 @@ pub use context::{ IMetagameContextSVGDispatcher, IMetagameContextSVGDispatcherTrait, }; -// Re-export commonly used items at top level -pub use core::{IMETAGAME_ID, IMetagame, IMetagameDispatcher, IMetagameDispatcherTrait}; diff --git a/packages/interfaces/src/metagame/core.cairo b/packages/interfaces/src/metagame/core.cairo index 4032f7a5..4e8ab945 100644 --- a/packages/interfaces/src/metagame/core.cairo +++ b/packages/interfaces/src/metagame/core.cairo @@ -1,13 +1,21 @@ // Core metagame interface -use starknet::ContractAddress; - -/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors -/// - context_address()->ContractAddress -/// - default_token_address()->ContractAddress -pub const IMETAGAME_ID: felt252 = 0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2; - -#[starknet::interface] -pub trait IMetagame { - fn context_address(self: @TContractState) -> ContractAddress; - fn default_token_address(self: @TContractState) -> ContractAddress; -} +// +// DELIBERATELY EMPTY. +// +// `IMetagame` and `IMETAGAME_ID` were removed. A metagame is self-bound — +// the embedding contract IS the metagame — so the two views the trait carried +// no longer exist: +// +// * `default_token_address()` — every game brings its own token, resolved from +// `game_address` on each mint. +// * `context_address()` — a metagame that provides context embeds +// `ContextComponent` itself and registers `IMETAGAME_CONTEXT_ID` on its own +// address. Nothing ever resolved a context provider through this view: the +// legacy token takes context as a mint parameter. +// +// With no methods left, an SRC5 id could not be derived (the XOR of an empty +// selector set is degenerate), and nothing in the ecosystem probed the old +// `IMETAGAME_ID` (0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2). +// Discover a metagame's capabilities through the surfaces that still carry +// meaning: `IMETAGAME_CONTEXT_ID` for a context provider and +// `IMETAGAME_CALLBACK_ID` for a legacy callback receiver. diff --git a/packages/interfaces/src/structs.cairo b/packages/interfaces/src/structs.cairo index b3788fca..6cd34b0b 100644 --- a/packages/interfaces/src/structs.cairo +++ b/packages/interfaces/src/structs.cairo @@ -17,6 +17,6 @@ pub use minigame::{ }; pub use registry::{GameFeeInfo, GameMetadata}; pub use token::{ - Lifecycle, MintBatchRecipient, MintParams, PlayerNameUpdate, TokenFullState, TokenMetadata, - TokenMutableState, + GameFeeTerms, Lifecycle, MintBatchRecipient, MintParams, PlayerNameUpdate, TokenFullState, + TokenMetadata, TokenMutableState, }; diff --git a/packages/interfaces/src/structs/token.cairo b/packages/interfaces/src/structs/token.cairo index 3e1e0bb5..2610cc85 100644 --- a/packages/interfaces/src/structs/token.cairo +++ b/packages/interfaces/src/structs/token.cairo @@ -3,6 +3,18 @@ use starknet::ContractAddress; use super::metagame::GameContextDetails; +/// A self-bound standard token's declared monetization terms: the fee +/// recipient (payee), license text and fee rate. Replaces the retired +/// registry's `GameFeeInfo` lookup: the terms live on the game/token +/// contract itself (see `token::game_fee`). +#[derive(Drop, Serde, Clone, PartialEq)] +pub struct GameFeeTerms { + pub recipient: ContractAddress, + pub license: ByteArray, + /// Fee in basis points (against `FEE_DENOMINATOR` = 10_000) + pub fee_numerator: u16, +} + #[derive(Copy, Drop, Serde)] pub struct Lifecycle { pub start: u64, diff --git a/packages/interfaces/src/token.cairo b/packages/interfaces/src/token.cairo index 9638fb59..d7f2c998 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -2,6 +2,8 @@ pub mod context; pub mod core; +pub mod game_fee; +pub mod legacy; pub mod minter; pub mod objectives; pub mod renderer; @@ -12,6 +14,15 @@ pub use context::IMINIGAME_TOKEN_CONTEXT_ID; // Re-export commonly used items at top level pub use core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, + MinigameTokenABI, MinigameTokenABIDispatcher, MinigameTokenABIDispatcherTrait, +}; +pub use game_fee::{ + IMINIGAME_TOKEN_GAME_FEE_ID, IMinigameTokenGameFee, IMinigameTokenGameFeeDispatcher, + IMinigameTokenGameFeeDispatcherTrait, +}; +pub use legacy::{ + IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, + IMinigameTokenLegacyDispatcherTrait, }; pub use minter::{ IMINIGAME_TOKEN_MINTER_ID, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, diff --git a/packages/interfaces/src/token/core.cairo b/packages/interfaces/src/token/core.cairo index 005480e6..6867d70f 100644 --- a/packages/interfaces/src/token/core.cairo +++ b/packages/interfaces/src/token/core.cairo @@ -1,56 +1,110 @@ -// Core token interface +// The minigame token STANDARD — single-game, no mutable token state. +// +// Surface for one-address deployments: the implementing component is embedded +// IN the game contract, so the game and the token are always the same +// contract, and the game contract remains the sole authority on game-over / +// objective completion. The token stores no per-token mutable state except +// `player_name` and `client_url`: every other view is unpacked from the token +// id itself. +// +// The ORIGINAL multi-game token trait (separate token contract, registry, +// mutable state) lives on as `IMinigameTokenLegacy` in `token/legacy.cairo`, +// kept for deployed denshokan. +// +// Token ids use the standard's 251-bit layout (see +// `game_components_embeddable_game_standard::token::packing`), NOT the +// legacy token's layout. Indexers must branch their token-id decoder by +// contract generation. +// +// Strip principle: dead MACHINERY and compat shims are deleted; CAPABILITY +// (writes) and cheap client-facing read views stay. +// * `game_address` / `game_registry_address` — gone: the pairing is +// self == self; consumers probe `IMINIGAME_TOKEN_ID` via SRC5 instead +// of resolving addresses. +// * `assert_is_playable` / `assert_owner_and_playable` — gone from the ABI: +// the embedding game's own guards, internal calls now +// (`MinigameTokenComponent::InternalTrait`); clients use `is_playable`. +// * `refresh_metadata_batch` — gone: a multicall of singles. +// * The legacy token's `game_address`, `renderer_address` and `skills_address` +// mint parameters are gone (self-bound; no per-token renderer/skills). +// +// Mint parameters kept WITH their original legacy-token behaviors: +// * `objective_id` — packed into the id as inert data the game interprets; +// the token has no completion machinery (`completed_objective` in +// `token_metadata` stays always-false). +// * `context` — sets the id's has_context bit only; the data itself is NOT +// stored (legacy-token parity: its context hook was a documented no-op and +// token_uri sourced context from the minter at render time). +// * `client_url` — storage-backed, readable via `client_url(token_id)`. +// * `paymaster` — packed bit. +// * `metadata` — widened from the legacy token's u16 to a u128 holding a +// 65-bit packed field; read via `mint_metadata(token_id)`. +// +// Semantics that differ from the legacy token: +// * `is_playable` checks the lifecycle window only. There is no token-side +// `game_over`/`completed_objective` latch — ask the game. +// * `token_metadata` reports `game_over`/`completed_objective`/`completed_at` +// as `false`/`0` unconditionally, for the same reason, and its u16 +// `metadata` field as 0 (the 65-bit packed value cannot fit — use +// `mint_metadata`). +// * There is no `update_game` — nothing to sync. `refresh_metadata` +// (ERC-4906 emit) is the only post-action hook a game needs. use starknet::ContractAddress; use crate::structs::metagame::GameContextDetails; -use crate::structs::token::{ - MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, -}; +use crate::structs::token::{MintBatchRecipient, TokenMetadata}; /// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. /// -/// Surface includes `mint`, `mint_batch_recipients(Array, ...)`, -/// `update_game`, and the read/batch-read methods. Run `src5_rs parse` against a -/// stripped copy of this trait (see packages/interfaces/src/AGENTS.md) to rederive. +/// Surface is the trait below minus `refresh_metadata`, mirroring the +/// refresh-function exclusion from `IMINIGAME_TOKEN_LEGACY_ID`. Run `src5_rs parse` +/// against a stripped copy of this trait (see packages/interfaces/src/AGENTS.md) +/// to rederive: +/// token_metadata: 0x1ebdf5dc7aab5a2b9bd68eb3a453bfb8025371633679db9d0d918cf87f92dd0 +/// is_playable: 0x2fbc9e87d82f279727e61c9ebc25269905fd28fb8137aeead5f417ac4cc66de +/// settings_id: 0x2c1ab8f675f7da818ca288b9feb48811492444b5e6d822b3d1fe07728d1b714 +/// player_name: 0x2cf33209d5df54b50609fc29863a6b916471ac903c3d15acbe89210cac085aa +/// minted_by: 0x1017c8450696b88787feabb9b5f2584574556b2091690953c038e051d5801bb +/// minted_by_address: 0x3c8691eac3f879268d352d7d5f6f28a456e3f92f4843fec780e3037d4f9d162 +/// is_soulbound: 0x38f66b071844d5c568a247092201c33b2ef3d3ac5bf07715050d15b213c48c2 +/// objective_id: 0x1c4b6eb95bb446da526020769358176d3498e17d9c19de091867d39d7aec5f6 +/// client_url: 0xfece505a913d6bf16c52441883915903c7f729b363edd8c5e632d00eec92d2 +/// mint_metadata: 0x336044a33f6a282d709d30cdd1b1ef63ea14c85c9e0d7cb14f51127fa7cfa36 +/// mint: 0x1bf0e27928426c321ad45df64c7ebb07bf82645eaecf532c67df90b4007692c +/// mint_batch_recipients: 0x144515c9b8cf0aa7bfe3e5c932f6d346730b53fa1515dd46a808bf5e055cbfe +/// update_player_name: 0x1f68f6ce969c632201a916c0ec4432e7edf5340a2b7a71172b820d22c2e9481 pub const IMINIGAME_TOKEN_ID: felt252 = - 0x246f614bd76b91c378a91877851f2ccdb99278e9fb77c782a22355059ce9906; + 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea; #[starknet::interface] pub trait IMinigameToken { fn token_metadata(self: @TState, token_id: felt252) -> TokenMetadata; + /// Lifecycle window only — no game_over latch; ask the game. fn is_playable(self: @TState, token_id: felt252) -> bool; - fn assert_is_playable(self: @TState, token_id: felt252); fn settings_id(self: @TState, token_id: felt252) -> u32; fn player_name(self: @TState, token_id: felt252) -> felt252; - fn objective_id(self: @TState, token_id: felt252) -> u32; fn minted_by(self: @TState, token_id: felt252) -> felt252; + /// Resolves the packed 26-bit minter id back to the minter's address — + /// the one view a packing-aware caller cannot derive from the id alone. fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; - fn game_address(self: @TState) -> ContractAddress; - fn game_registry_address(self: @TState) -> ContractAddress; fn is_soulbound(self: @TState, token_id: felt252) -> bool; - fn renderer_address(self: @TState, token_id: felt252) -> ContractAddress; - fn token_game_address(self: @TState, token_id: felt252) -> ContractAddress; - fn token_mutable_state(self: @TState, token_id: felt252) -> TokenMutableState; + /// Packed objective id — inert data the game interprets; the token + /// has no completion machinery. + fn objective_id(self: @TState, token_id: felt252) -> u32; + /// Stored client url from mint; empty ByteArray when none was supplied. fn client_url(self: @TState, token_id: felt252) -> ByteArray; - fn skills_address(self: @TState, token_id: felt252) -> ContractAddress; - - // Batch view functions - fn token_metadata_batch(self: @TState, token_ids: Span) -> Array; - fn is_playable_batch(self: @TState, token_ids: Span) -> Array; - fn settings_id_batch(self: @TState, token_ids: Span) -> Array; - fn player_name_batch(self: @TState, token_ids: Span) -> Array; - fn objective_id_batch(self: @TState, token_ids: Span) -> Array; - fn minted_by_batch(self: @TState, token_ids: Span) -> Array; - fn minted_by_address_batch(self: @TState, token_ids: Span) -> Array; - fn is_soulbound_batch(self: @TState, token_ids: Span) -> Array; - fn renderer_address_batch(self: @TState, token_ids: Span) -> Array; - fn token_game_address_batch(self: @TState, token_ids: Span) -> Array; - fn token_mutable_state_batch( - self: @TState, token_ids: Span, - ) -> Array; - fn token_full_state_batch(self: @TState, token_ids: Span) -> Array; + /// The 65-bit packed mint metadata field — the value the u16 `metadata` + /// field of `token_metadata` cannot hold. + fn mint_metadata(self: @TState, token_id: felt252) -> u128; + /// Mints to `to` and returns the packed token id. The game is this + /// contract — there is no game_address parameter. `settings_id` keeps + /// `Option` for call-site ergonomics, but the value must fit the + /// id layout's 16-bit field (`<= 0xFFFF`) or the mint reverts; likewise + /// `objective_id` must fit 30 bits and `metadata` 65 bits. `context` sets + /// the id's has_context bit only (data not stored); `client_url` is + /// written to storage when Some. fn mint( ref self: TState, - game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -58,27 +112,19 @@ pub trait IMinigameToken { objective_id: Option, context: Option, client_url: Option, - renderer_address: Option, - skills_address: Option, to: ContractAddress, soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; - /// Batch mint identical tokens to one or more recipients with per-recipient counts. - /// - /// All recipients share the same mint configuration (game, settings, objective, - /// lifecycle, context, renderer, skills, soulbound, paymaster, metadata). - /// Each `MintBatchRecipient { to, count }` mints `count` tokens to `to`. - /// - /// Salt assignment is a single global counter across the batch (`base_salt + i`, - /// `i` in `0..sum(counts)`). Token ids do not encode the recipient, so salts - /// must be globally unique within the tx — `salt + sum(counts) - 1 <= 0x3FF` - /// (10-bit salt field in `pack_token_id`). + /// Batch mint with per-recipient counts. Salt is a single global counter + /// across the batch (`salt + sum(counts) - 1 <= 0xFFFF` — the id + /// layout's 16-bit salt field). All packed fields (including the + /// has_context bit) are shared by every minted token; the client_url, when + /// Some, is written per token. fn mint_batch_recipients( ref self: TState, - game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -86,21 +132,86 @@ pub trait IMinigameToken { objective_id: Option, context: Option, client_url: Option, - renderer_address: Option, - skills_address: Option, recipients: Array, soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> Array; + /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see + /// `IMinigameTokenLegacy::refresh_metadata` for the spam/existence trade-offs; + /// identical semantics here. + fn refresh_metadata(ref self: TState, token_id: felt252); + /// Owner-gated rename; emits `MetadataUpdate`. + fn update_player_name(ref self: TState, token_id: felt252, name: felt252); +} - fn update_game(ref self: TState, token_id: felt252); +/// Combined mixin ABI: the full external surface of the standard token — +/// `IMinigameToken` + the absorbed minter (`IMinigameTokenMinter`) + the +/// game-fee surface (`IMinigameTokenGameFee`) — as ONE embeddable trait, +/// mirroring OpenZeppelin's ERC20ABI / MixinImpl pattern. +/// +/// The component's `initializer` registers all three SRC5 ids +/// unconditionally; embedding `MinigameTokenComponent::MinigameTokenMixinImpl` +/// (instead of the three impls separately) guarantees the advertised ids can +/// never diverge from the exposed entrypoints. NOT used for SRC5 id +/// derivation — the ids remain `IMINIGAME_TOKEN_ID`, +/// `IMINIGAME_TOKEN_MINTER_ID` and `IMINIGAME_TOKEN_GAME_FEE_ID`. +#[starknet::interface] +pub trait MinigameTokenABI { + // IMinigameToken + fn token_metadata(self: @TState, token_id: felt252) -> TokenMetadata; + fn is_playable(self: @TState, token_id: felt252) -> bool; + fn settings_id(self: @TState, token_id: felt252) -> u32; + fn player_name(self: @TState, token_id: felt252) -> felt252; + fn minted_by(self: @TState, token_id: felt252) -> felt252; + fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; + fn is_soulbound(self: @TState, token_id: felt252) -> bool; + fn objective_id(self: @TState, token_id: felt252) -> u32; + fn client_url(self: @TState, token_id: felt252) -> ByteArray; + fn mint_metadata(self: @TState, token_id: felt252) -> u128; + fn mint( + ref self: TState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> felt252; + fn mint_batch_recipients( + ref self: TState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u128, + ) -> Array; fn refresh_metadata(ref self: TState, token_id: felt252); fn update_player_name(ref self: TState, token_id: felt252, name: felt252); - // Batch write functions - fn update_game_batch(ref self: TState, token_ids: Span); - fn refresh_metadata_batch(ref self: TState, token_ids: Span); - fn update_player_name_batch(ref self: TState, updates: Span); + // IMinigameTokenMinter (absorbed minter registry) + fn get_minter_address(self: @TState, minter_id: u64) -> ContractAddress; + fn get_minter_id(self: @TState, minter_address: ContractAddress) -> u64; + fn minter_exists(self: @TState, minter_address: ContractAddress) -> bool; + fn total_minters(self: @TState) -> u64; + + // IMinigameTokenGameFee (game fee recipient + terms) + fn game_fee_terms(self: @TState) -> crate::structs::token::GameFeeTerms; + fn game_fee_recipient(self: @TState) -> ContractAddress; + fn set_game_fee_recipient(ref self: TState, new_recipient: ContractAddress); + fn set_game_fee(ref self: TState, license: ByteArray, fee_numerator: u16); } diff --git a/packages/interfaces/src/token/game_fee.cairo b/packages/interfaces/src/token/game_fee.cairo new file mode 100644 index 00000000..99229117 --- /dev/null +++ b/packages/interfaces/src/token/game_fee.cairo @@ -0,0 +1,53 @@ +// Token game-fee extension interface +// +// The registry used to carry a game's monetization terms (the payee was the +// registry NFT's owner; the fee came from `GameFeeInfo`). With the self-bound +// standard token there is no registry, so the terms live on the token +// standard itself: the GAME FEE RECIPIENT (a payout sink), license text and +// fee rate — set at initialization, administered by the game contract's OZ +// Ownable OWNER, and discoverable via SRC5 so monetization platforms +// (e.g. Budokan) can resolve the payee and minimum fee live at claim time. +use starknet::ContractAddress; +pub use crate::registry::{DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR}; +pub use crate::structs::token::GameFeeTerms; + +/// Default license text for the STANDARD token's game-fee surface. +/// +/// Deliberately fee-amount-free: the authoritative fee is the on-chain +/// `fee_numerator` (owner-rotatable via `set_game_fee`), so a number baked +/// into the text would go stale on the first fee change. The text points +/// consumers at the on-chain declaration instead and resolves both rate and +/// payee at time of payment. The legacy registry keeps its own v1.0 text +/// (`registry::default_license`) — that string is what deployed denshokan +/// carries and stays frozen. +pub fn default_license() -> ByteArray { + "Embeddable Game Standard License v1.1. Monetization of this game requires payment of the game fee this contract declares on-chain, at the rate and to the game fee recipient in effect at the time of payment (game_fee_terms). Non-monetized integration, indexing, and display of this game are unrestricted." +} + +/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors +/// - game_fee_terms()->(ContractAddress,(Array,felt252,usize),u16) +/// 0x20c5807da22bc9761d1c7ec0dcb7ffc708e90d756697f5a1440f7b24d061163 +/// - game_fee_recipient()->ContractAddress +/// 0x250cb6126e904ba7bcdc6f75a708a0e65255b0b08fdb353e130e1a16af98a5c +/// - set_game_fee_recipient(ContractAddress) +/// 0x21cadbae1c0d69501e65651c352fe97ad44f9b12c638b8863befbda7bb3196d +/// - set_game_fee((Array,felt252,usize),u16) +/// 0x3318144fd81873b0e256922d3972f42e61a473c6336dfcc2e9f2e8defdcf9e2 +/// +/// (Renamed from the creator surface: function renames change extended +/// selectors, so this VALUE differs from the retired +/// IMINIGAME_TOKEN_CREATOR_ID 0x21531c… — which no deployment registers.) +pub const IMINIGAME_TOKEN_GAME_FEE_ID: felt252 = + 0x171bf98e08ae98315df3e68477e24275ef5755111c1984db851c344b3907bb0; + +#[starknet::interface] +pub trait IMinigameTokenGameFee { + fn game_fee_terms(self: @TState) -> GameFeeTerms; + fn game_fee_recipient(self: @TState) -> ContractAddress; + /// Rotate the payee. Gated on the game contract's Ownable owner; the new + /// address must be non-zero (rotation must never brick the payee). + fn set_game_fee_recipient(ref self: TState, new_recipient: ContractAddress); + /// Update the license text and fee. Gated on the game contract's Ownable + /// owner; `fee_numerator` is in basis points, capped at `FEE_DENOMINATOR`. + fn set_game_fee(ref self: TState, license: ByteArray, fee_numerator: u16); +} diff --git a/packages/interfaces/src/token/legacy.cairo b/packages/interfaces/src/token/legacy.cairo new file mode 100644 index 00000000..96640661 --- /dev/null +++ b/packages/interfaces/src/token/legacy.cairo @@ -0,0 +1,116 @@ +// Legacy token interface — the ORIGINAL multi-game minigame token (separate +// token contract, registry-backed, mutable token state synced via +// `update_game`). Kept for deployed denshokan; the minigame token STANDARD is +// now the single-game, self-bound trait in `token/core.cairo` +// (`IMinigameToken`). +use starknet::ContractAddress; +use crate::structs::metagame::GameContextDetails; +use crate::structs::token::{ + MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, +}; + +/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. +/// +/// Surface includes `mint`, `mint_batch_recipients(Array, ...)`, +/// `update_game`, and the read/batch-read methods. Run `src5_rs parse` against a +/// stripped copy of this trait (see packages/interfaces/src/AGENTS.md) to rederive. +/// +/// FROZEN VALUE: deployed denshokan contracts register this id on-chain under +/// its original derivation, from when this trait was named `IMinigameToken`. +/// The standard NAME (`IMINIGAME_TOKEN_ID`) moved to the new self-bound token, +/// but this value must never change — it identifies the legacy surface that +/// live contracts advertise. +pub const IMINIGAME_TOKEN_LEGACY_ID: felt252 = + 0x246f614bd76b91c378a91877851f2ccdb99278e9fb77c782a22355059ce9906; + +#[starknet::interface] +pub trait IMinigameTokenLegacy { + fn token_metadata(self: @TState, token_id: felt252) -> TokenMetadata; + fn is_playable(self: @TState, token_id: felt252) -> bool; + fn assert_is_playable(self: @TState, token_id: felt252); + fn settings_id(self: @TState, token_id: felt252) -> u32; + fn player_name(self: @TState, token_id: felt252) -> felt252; + fn objective_id(self: @TState, token_id: felt252) -> u32; + fn minted_by(self: @TState, token_id: felt252) -> felt252; + fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; + fn game_address(self: @TState) -> ContractAddress; + fn game_registry_address(self: @TState) -> ContractAddress; + fn is_soulbound(self: @TState, token_id: felt252) -> bool; + fn renderer_address(self: @TState, token_id: felt252) -> ContractAddress; + fn token_game_address(self: @TState, token_id: felt252) -> ContractAddress; + fn token_mutable_state(self: @TState, token_id: felt252) -> TokenMutableState; + fn client_url(self: @TState, token_id: felt252) -> ByteArray; + fn skills_address(self: @TState, token_id: felt252) -> ContractAddress; + + // Batch view functions + fn token_metadata_batch(self: @TState, token_ids: Span) -> Array; + fn is_playable_batch(self: @TState, token_ids: Span) -> Array; + fn settings_id_batch(self: @TState, token_ids: Span) -> Array; + fn player_name_batch(self: @TState, token_ids: Span) -> Array; + fn objective_id_batch(self: @TState, token_ids: Span) -> Array; + fn minted_by_batch(self: @TState, token_ids: Span) -> Array; + fn minted_by_address_batch(self: @TState, token_ids: Span) -> Array; + fn is_soulbound_batch(self: @TState, token_ids: Span) -> Array; + fn renderer_address_batch(self: @TState, token_ids: Span) -> Array; + fn token_game_address_batch(self: @TState, token_ids: Span) -> Array; + fn token_mutable_state_batch( + self: @TState, token_ids: Span, + ) -> Array; + fn token_full_state_batch(self: @TState, token_ids: Span) -> Array; + + fn mint( + ref self: TState, + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> felt252; + /// Batch mint identical tokens to one or more recipients with per-recipient counts. + /// + /// All recipients share the same mint configuration (game, settings, objective, + /// lifecycle, context, renderer, skills, soulbound, paymaster, metadata). + /// Each `MintBatchRecipient { to, count }` mints `count` tokens to `to`. + /// + /// Salt assignment is a single global counter across the batch (`base_salt + i`, + /// `i` in `0..sum(counts)`). Token ids do not encode the recipient, so salts + /// must be globally unique within the tx — `salt + sum(counts) - 1 <= 0x3FF` + /// (10-bit salt field in `pack_token_id`). + fn mint_batch_recipients( + ref self: TState, + game_address: ContractAddress, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + recipients: Array, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> Array; + + fn update_game(ref self: TState, token_id: felt252); + fn refresh_metadata(ref self: TState, token_id: felt252); + fn update_player_name(ref self: TState, token_id: felt252, name: felt252); + + // Batch write functions + fn update_game_batch(ref self: TState, token_ids: Span); + fn refresh_metadata_batch(ref self: TState, token_ids: Span); + fn update_player_name_batch(ref self: TState, updates: Span); +} diff --git a/packages/interfaces/src/tokenomics/buyback.cairo b/packages/interfaces/src/tokenomics/buyback.cairo index 9e2e12f9..f61cce3a 100644 --- a/packages/interfaces/src/tokenomics/buyback.cairo +++ b/packages/interfaces/src/tokenomics/buyback.cairo @@ -144,6 +144,12 @@ pub trait IBuybackAdmin { /// Set the global configuration defaults fn set_global_config(ref self: TContractState, config: GlobalBuybackConfig); + /// Toggle strict per-token config mode. When enabled, tokens WITHOUT an + /// explicit per-token config revert (`'No config for token'`) instead of + /// falling back to the global defaults — governance decides which tokens + /// are tradeable. Off by default (global fallback, the historic behavior). + fn set_require_token_config(ref self: TContractState, required: bool); + /// Set or clear per-token configuration fn set_token_config( ref self: TContractState, sell_token: ContractAddress, config: Option, diff --git a/packages/metagame/src/leaderboard/tests/test_leaderboard_pure.cairo b/packages/metagame/src/leaderboard/tests/test_leaderboard_pure.cairo index f5b82a51..01f50a36 100644 --- a/packages/metagame/src/leaderboard/tests/test_leaderboard_pure.cairo +++ b/packages/metagame/src/leaderboard/tests/test_leaderboard_pure.cairo @@ -1,5 +1,5 @@ // Pure leaderboard library tests -use game_components_embeddable_game_standard::token::structs::pack_token_id; +use game_components_embeddable_game_standard::token_legacy::structs::pack_token_id; use game_components_interfaces::leaderboard::LeaderboardResult; use game_components_metagame::leaderboard::leaderboard::leaderboard; diff --git a/packages/metagame/src/ticket_booth/tests/test_ticket_booth.cairo b/packages/metagame/src/ticket_booth/tests/test_ticket_booth.cairo index 1977783b..b888b069 100644 --- a/packages/metagame/src/ticket_booth/tests/test_ticket_booth.cairo +++ b/packages/metagame/src/ticket_booth/tests/test_ticket_booth.cairo @@ -1000,7 +1000,7 @@ mod MockMinigameTokenForTicketBooth { Lifecycle, MintBatchRecipient, PlayerNameUpdate, TokenFullState, TokenMetadata, TokenMutableState, }; - use game_components_interfaces::token::{IMINIGAME_TOKEN_ID, IMinigameToken}; + use game_components_interfaces::token::{IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy}; use openzeppelin_interfaces::introspection::ISRC5; use starknet::ContractAddress; use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; @@ -1016,7 +1016,7 @@ mod MockMinigameTokenForTicketBooth { } #[abi(embed_v0)] - impl MinigameTokenImpl of IMinigameToken { + impl MinigameTokenImpl of IMinigameTokenLegacy { fn token_metadata(self: @ContractState, token_id: felt252) -> TokenMetadata { TokenMetadata { game_id: 0, @@ -1214,7 +1214,7 @@ mod MockMinigameTokenForTicketBooth { #[abi(embed_v0)] impl SRC5Impl of ISRC5 { fn supports_interface(self: @ContractState, interface_id: felt252) -> bool { - interface_id == IMINIGAME_TOKEN_ID + interface_id == IMINIGAME_TOKEN_LEGACY_ID || interface_id == openzeppelin_interfaces::introspection::ISRC5_ID } } diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml index 3b235ca1..fd7d2d8c 100644 --- a/packages/presets/Scarb.toml +++ b/packages/presets/Scarb.toml @@ -8,11 +8,13 @@ edition.workspace = true [dependencies] game_components_metagame = { path = "../metagame" } game_components_economy = { path = "../economy" } +game_components_embeddable_game_standard = { path = "../embeddable_game_standard" } game_components_interfaces = { path = "../interfaces" } starknet.workspace = true openzeppelin_token.workspace = true openzeppelin_introspection.workspace = true openzeppelin_access.workspace = true +openzeppelin_interfaces.workspace = true ekubo.workspace = true [[target.starknet-contract]] diff --git a/packages/presets/src/autonomous_buyback.cairo b/packages/presets/src/autonomous_buyback.cairo index bf4f5494..5ec74a9f 100644 --- a/packages/presets/src/autonomous_buyback.cairo +++ b/packages/presets/src/autonomous_buyback.cairo @@ -95,6 +95,13 @@ pub mod AutonomousBuyback { self.buyback.set_global_config(config); } + /// Toggle strict per-token config mode (owner only): when enabled, + /// only explicitly-configured tokens can be bought back + fn set_require_token_config(ref self: ContractState, required: bool) { + self.ownable.assert_only_owner(); + self.buyback.set_require_token_config(required); + } + /// Set or clear per-token configuration (owner only) /// None = use global defaults, Some = override with specific config fn set_token_config( diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index 70afc81c..6b211908 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -1,15 +1,15 @@ // SPDX-License-Identifier: BUSL-1.1 -/// # Game Components Presets -/// -/// Ready-to-deploy contracts built with game components. -/// These presets provide simple, generic implementations suitable for -/// common gaming use cases without requiring custom contract development. -/// -/// ## Available Presets -/// - **Leaderboard**: Tournament leaderboard management with scoring and ranking -/// - **AutonomousBuyback**: Autonomous token buyback via Ekubo TWAMM -/// - **StreamToken**: ERC20 token with built-in TWAMM distribution +//! # Game Components Presets +//! +//! Ready-to-deploy contracts built with game components. +//! These presets provide simple, generic implementations suitable for +//! common gaming use cases without requiring custom contract development. +//! +//! ## Available Presets +//! - **Leaderboard**: Tournament leaderboard management with scoring and ranking +//! - **AutonomousBuyback**: Autonomous token buyback via Ekubo TWAMM +//! - **StreamToken**: ERC20 token with built-in TWAMM distribution pub mod autonomous_buyback; pub mod leaderboard; diff --git a/packages/test_common/src/AGENTS.md b/packages/test_common/src/AGENTS.md index 6a86024a..dc72fbed 100644 --- a/packages/test_common/src/AGENTS.md +++ b/packages/test_common/src/AGENTS.md @@ -24,6 +24,7 @@ Located in `src/mocks/`: | Mock | Purpose | |------|---------| +| `standard_game_mock.cairo` | Merged one-address game+token: embeds `MinigameTokenComponent` (self-bound, absorbed minter registry) with `IMinigameTokenData`, `IMinigame` views, and settings | | `metagame_mock.cairo` | Metagame component mock with callback tracking | | `minigame_mock.cairo` | Full minigame mock with settings, objectives, and scoring | | `mock_erc20.cairo` | ERC20 token with mint/burn for testing | diff --git a/packages/test_common/src/examples/full_token_contract.cairo b/packages/test_common/src/examples/full_token_contract.cairo index 7b9b4fc3..6499bd51 100644 --- a/packages/test_common/src/examples/full_token_contract.cairo +++ b/packages/test_common/src/examples/full_token_contract.cairo @@ -13,14 +13,14 @@ use game_components_embeddable_game_standard::minigame::structs::GameDetail; use game_components_embeddable_game_standard::registry::interface::{ IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, }; -use game_components_embeddable_game_standard::token::extensions::context::context::ContextComponent; -use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; -use game_components_embeddable_game_standard::token::extensions::objectives::objectives::ObjectivesComponent; -use game_components_embeddable_game_standard::token::extensions::renderer::renderer::RendererComponent; -use game_components_embeddable_game_standard::token::extensions::settings::settings::SettingsComponent; -use game_components_embeddable_game_standard::token::extensions::skills::skills::SkillsComponent; -use game_components_embeddable_game_standard::token::structs::TokenMetadata; -use game_components_embeddable_game_standard::token::token_component::CoreTokenComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::context::context::ContextComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::minter::minter::MinterComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::objectives::objectives::ObjectivesComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::renderer::renderer::RendererComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::settings::settings::SettingsComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::skills::skills::SkillsComponent; +use game_components_embeddable_game_standard::token_legacy::structs::TokenMetadata; +use game_components_embeddable_game_standard::token_legacy::token_component::CoreTokenComponent; use game_components_utilities::renderer::metadata::create_custom_metadata; use game_components_utilities::renderer::svg::create_default_svg; use openzeppelin_interfaces::erc2981::IERC2981; diff --git a/packages/test_common/src/examples/minigame_registry_contract.cairo b/packages/test_common/src/examples/minigame_registry_contract.cairo index d3480a4e..a361a4f1 100644 --- a/packages/test_common/src/examples/minigame_registry_contract.cairo +++ b/packages/test_common/src/examples/minigame_registry_contract.cairo @@ -7,7 +7,7 @@ pub use game_components_embeddable_game_standard::registry::interface::{ pub mod MinigameRegistryContract { use core::num::traits::Zero; use game_components_embeddable_game_standard::minigame::interface::IMINIGAME_ID; - use game_components_embeddable_game_standard::token::interface::{ + use game_components_embeddable_game_standard::token_legacy::interface::{ ITokenEventRelayerDispatcher, ITokenEventRelayerDispatcherTrait, }; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; @@ -18,7 +18,7 @@ pub mod MinigameRegistryContract { }; use starknet::{ContractAddress, get_caller_address}; // use - // game_components_embeddable_game_standard::token::extensions::multi_game::interface::{IMinigameTokenMultiGame}; + // game_components_embeddable_game_standard::token_legacy::extensions::multi_game::interface::{IMinigameTokenMultiGame}; use super::{GameFeeInfo, GameMetadata}; use super::{IMINIGAME_REGISTRY_ID, IMinigameRegistry}; diff --git a/packages/test_common/src/examples/minimal_optimized_example.cairo b/packages/test_common/src/examples/minimal_optimized_example.cairo index 6aecebee..0e946f5e 100644 --- a/packages/test_common/src/examples/minimal_optimized_example.cairo +++ b/packages/test_common/src/examples/minimal_optimized_example.cairo @@ -2,11 +2,11 @@ // This demonstrates the optimal configurable direct components architecture // Import the optimal components - use actual package paths -use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; -use game_components_embeddable_game_standard::token::noop_traits::{ +use game_components_embeddable_game_standard::token_legacy::extensions::minter::minter::MinterComponent; +use game_components_embeddable_game_standard::token_legacy::noop_traits::{ NoOpContext, NoOpObjectives, NoOpRenderer, NoOpSettings, NoOpSkills, NoOpSoulbound, }; -use game_components_embeddable_game_standard::token::token_component::CoreTokenComponent; +use game_components_embeddable_game_standard::token_legacy::token_component::CoreTokenComponent; use openzeppelin_interfaces::erc2981::IERC2981; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_token::common::erc2981::erc2981::{DefaultConfig, ERC2981Component}; diff --git a/packages/test_common/src/examples/single_game_token_contract.cairo b/packages/test_common/src/examples/single_game_token_contract.cairo index f7cc64f8..0f8d13b7 100644 --- a/packages/test_common/src/examples/single_game_token_contract.cairo +++ b/packages/test_common/src/examples/single_game_token_contract.cairo @@ -8,16 +8,16 @@ use game_components_embeddable_game_standard::metagame::extensions::context::str use game_components_embeddable_game_standard::minigame::extensions::settings::structs::GameSettingDetails; use game_components_embeddable_game_standard::minigame::structs::GameDetail; use game_components_embeddable_game_standard::registry::interface::GameMetadata; -use game_components_embeddable_game_standard::token::extensions::context::context::ContextComponent; -use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; -use game_components_embeddable_game_standard::token::extensions::objectives::objectives::ObjectivesComponent; -use game_components_embeddable_game_standard::token::extensions::renderer::renderer::RendererComponent; -use game_components_embeddable_game_standard::token::extensions::settings::settings::SettingsComponent; -use game_components_embeddable_game_standard::token::extensions::skills::skills::SkillsComponent; -use game_components_embeddable_game_standard::token::structs::TokenMetadata; +use game_components_embeddable_game_standard::token_legacy::extensions::context::context::ContextComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::minter::minter::MinterComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::objectives::objectives::ObjectivesComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::renderer::renderer::RendererComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::settings::settings::SettingsComponent; +use game_components_embeddable_game_standard::token_legacy::extensions::skills::skills::SkillsComponent; +use game_components_embeddable_game_standard::token_legacy::structs::TokenMetadata; // Game components imports - use the actual package paths -use game_components_embeddable_game_standard::token::token_component::CoreTokenComponent; +use game_components_embeddable_game_standard::token_legacy::token_component::CoreTokenComponent; use game_components_utilities::renderer::metadata::create_custom_metadata; use openzeppelin_interfaces::erc2981::IERC2981; use openzeppelin_interfaces::erc721::IERC721Metadata; diff --git a/packages/test_common/src/mocks.cairo b/packages/test_common/src/mocks.cairo index a2d5d96e..ec2adae9 100644 --- a/packages/test_common/src/mocks.cairo +++ b/packages/test_common/src/mocks.cairo @@ -11,3 +11,4 @@ pub mod mock_objectives_contract; pub mod mock_registry_contract; pub mod mock_rejecting_entry_validator; pub mod mock_settings_contract; +pub mod standard_game_mock; diff --git a/packages/test_common/src/mocks/metagame_mock.cairo b/packages/test_common/src/mocks/metagame_mock.cairo index c9aa15bd..566b04cb 100644 --- a/packages/test_common/src/mocks/metagame_mock.cairo +++ b/packages/test_common/src/mocks/metagame_mock.cairo @@ -4,7 +4,7 @@ use starknet::ContractAddress; pub trait IMetagameMock { fn mint_game( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -17,7 +17,7 @@ pub trait IMetagameMock { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -84,8 +84,6 @@ pub mod metagame_mock { } } - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; impl ContextInternalImpl = ContextComponent::InternalImpl; @@ -170,7 +168,7 @@ pub mod metagame_mock { impl MetagameMockImpl of super::IMetagameMock { fn mint_game( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -183,7 +181,7 @@ pub mod metagame_mock { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { let context = array![GameContext { name: 'Test Context 1', value: 'Test Context' }] .span(); @@ -256,7 +254,6 @@ pub mod metagame_mock { supports_context: bool, ) { // Initialize the metagame component - self.metagame.initializer(context_address, minigame_token_address); // Initialize local storage self.token_counter.write(0); @@ -267,7 +264,7 @@ pub mod metagame_mock { } // Initialize callback component (registers SRC5 interface) - self.callback.initializer(); + self.callback.initializer(minigame_token_address); } } } diff --git a/packages/test_common/src/mocks/mock_enumerable.cairo b/packages/test_common/src/mocks/mock_enumerable.cairo index 2d857f15..d702e24a 100644 --- a/packages/test_common/src/mocks/mock_enumerable.cairo +++ b/packages/test_common/src/mocks/mock_enumerable.cairo @@ -2,7 +2,7 @@ /// the felt252-optimized enumerable extension. #[starknet::contract] pub mod EnumerableMock { - use game_components_embeddable_game_standard::token::extensions::enumerable::enumerable::EnumerableComponent; + use game_components_embeddable_game_standard::token_legacy::extensions::enumerable::enumerable::EnumerableComponent; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_token::erc721::ERC721Component; use starknet::ContractAddress; diff --git a/packages/test_common/src/mocks/standard_game_mock.cairo b/packages/test_common/src/mocks/standard_game_mock.cairo new file mode 100644 index 00000000..00e84f0a --- /dev/null +++ b/packages/test_common/src/mocks/standard_game_mock.cairo @@ -0,0 +1,375 @@ +// Merged one-address mock: ONE contract that is both the game and the +// standard token. This is the only supported shape for +// `MinigameTokenComponent` — the component is self-binding, so the game +// contract IS the token. +// +// The contract wires: +// * ERC721 + SRC5 + MinigameTokenComponent (which carries the absorbed +// minter registry — no separate MinterComponent), with the soulbound +// transfer guard in `before_update` (pure `unpack_soulbound` from the +// standard `token::packing` layout). +// * `IMinigame` views that all return the contract's own address, plus +// `mint_game`/`mint_game_batch` delegating to the embedded token. +// * `IMinigameTokenData` from local maps, with test setters `set_score` / +// `end_game` mirroring minigame_mock's semantics. +// * `IMinigameSettings` + minigame_mock-style `create_settings_difficulty` +// storing locally. The game-side `SettingsComponent::create_settings` +// announcement runs against this contract itself; its SRC5 guard sees no +// token-side settings surface and silently skips — good coverage of the +// standard-token announcement path. + +#[starknet::interface] +pub trait IStandardGameMock { + fn set_score(ref self: TContractState, token_id: felt252, score: u64); + fn end_game(ref self: TContractState, token_id: felt252, score: u64); + fn create_settings_difficulty( + ref self: TContractState, name: ByteArray, description: ByteArray, difficulty: u8, + ); + /// Test-only exposure of the component's internal pre-action guard — + /// the way a real game consumes it inside its own entrypoints. + fn assert_owner_and_playable( + self: @TContractState, token_id: felt252, expected_owner: starknet::ContractAddress, + ); +} + +#[starknet::contract] +pub mod StandardGameMock { + use core::num::traits::Zero; + use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; + use game_components_embeddable_game_standard::minigame::extensions::settings::interface::IMinigameSettings; + use game_components_embeddable_game_standard::minigame::extensions::settings::settings::SettingsComponent; + use game_components_embeddable_game_standard::minigame::extensions::settings::structs::{ + GameSetting, GameSettingDetails, + }; + use game_components_embeddable_game_standard::minigame::interface::{ + IMINIGAME_ID, IMinigame, IMinigameTokenData, + }; + use game_components_embeddable_game_standard::minigame::structs::MintGameParams; + use game_components_embeddable_game_standard::token::interface::{ + IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, + }; + use game_components_embeddable_game_standard::token::minigame_token_component::MinigameTokenComponent; + use game_components_embeddable_game_standard::token::packing::unpack_soulbound; + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; + use openzeppelin_token::erc721::ERC721Component; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use starknet::{ContractAddress, get_contract_address}; + + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: MinigameTokenComponent, storage: minigame_token, event: MinigameTokenEvent); + component!(path: SettingsComponent, storage: settings, event: SettingsEvent); + // Required by the token component's GameFeeImpl (hard HasComponent bound): + // the game-fee surface is administered by the contract owner. + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + minigame_token: MinigameTokenComponent::Storage, + #[substorage(v0)] + settings: SettingsComponent::Storage, + #[substorage(v0)] + ownable: OwnableComponent::Storage, + // Game state — the game contract is the sole authority on score and + // game-over; the standard token holds no mutable state. + scores: Map, + game_over: Map, + // Settings storage (minigame_mock-style) + settings_count: u32, + settings_difficulty: Map, // settings_id -> difficulty + settings_details: Map< + u32, (ByteArray, ByteArray, bool), + > // settings_id -> (name, description, exists) + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + MinigameTokenEvent: MinigameTokenComponent::Event, + #[flat] + SettingsEvent: SettingsComponent::Event, + #[flat] + OwnableEvent: OwnableComponent::Event, + } + + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + #[abi(embed_v0)] + impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + // One embed for the full standard surface (token + absorbed minter + + // game fee) — the mixin keeps the SRC5 ids registered by the initializer + // honest by construction. + #[abi(embed_v0)] + impl MinigameTokenMixinImpl = + MinigameTokenComponent::MinigameTokenMixinImpl; + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableImpl; + + impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl SRC5InternalImpl = SRC5Component::InternalImpl; + impl MinigameTokenInternalImpl = MinigameTokenComponent::InternalImpl; + impl SettingsInternalImpl = SettingsComponent::InternalImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + + impl ERC721HooksImpl of ERC721Component::ERC721HooksTrait { + fn before_update( + ref self: ERC721Component::ComponentState, + to: ContractAddress, + token_id: u256, + auth: ContractAddress, + ) { + // Soulbound is a bit in the token id — pure unpack, no storage. + // Only transfers are blocked; mints (owner == 0) and burns + // (to == 0) pass through. + let current_owner = self._owner_of(token_id); + if !current_owner.is_zero() && !to.is_zero() { + if unpack_soulbound(token_id.try_into().unwrap()) { + panic!("Token is soulbound and cannot be transferred"); + } + } + } + + fn after_update( + ref self: ERC721Component::ComponentState, + to: ContractAddress, + token_id: u256, + auth: ContractAddress, + ) {} + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + base_uri: ByteArray, + game_fee_recipient: ContractAddress, + owner: ContractAddress, + ) { + self.erc721.initializer(name, symbol, base_uri); + // The owner administers the game-fee surface (assert_only_owner gate). + self.ownable.initializer(owner); + // Self-binding: no game argument — this contract IS the game. Also + // registers the absorbed minter registry's IMINIGAME_TOKEN_MINTER_ID + // and the game-fee surface's IMINIGAME_TOKEN_GAME_FEE_ID (recipient set + // here; license/fee left to the ecosystem defaults). + self.minigame_token.initializer(game_fee_recipient, Option::None, Option::None); + // Registers IMINIGAME_SETTINGS_ID (mirrors minigame_mock). + self.settings.initializer(); + self.src5.register_interface(IMINIGAME_ID); + } + + /// The one-address shape made concrete: every address the game advertises + /// is this contract. + #[abi(embed_v0)] + impl MinigameImpl of IMinigame { + fn token_address(self: @ContractState) -> ContractAddress { + get_contract_address() + } + + fn settings_address(self: @ContractState) -> ContractAddress { + get_contract_address() + } + + fn objectives_address(self: @ContractState) -> ContractAddress { + get_contract_address() + } + + /// `IMinigame::mint_game` keeps the full 15-arg trait shape. The + /// standard mint carries objective/context/client_url/paymaster/ + /// metadata with their original legacy-token behaviors, so those + /// forward naturally (the 15-arg u16 metadata widens into the + /// standard's u128 field via `.into()`); only renderer/skills — which + /// the standard token has no surface for — must be neutral, rejected + /// here rather than silently discarded. + fn mint_game( + self: @ContractState, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + objective_id: Option, + context: Option, + client_url: Option, + renderer_address: Option, + skills_address: Option, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> felt252 { + assert!(renderer_address.is_none(), "StandardGameMock: renderer not supported"); + assert!(skills_address.is_none(), "StandardGameMock: skills not supported"); + let token = IMinigameTokenDispatcher { contract_address: get_contract_address() }; + token + .mint( + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + to, + soulbound, + paymaster, + salt, + metadata.into(), + ) + } + + fn mint_game_batch(self: @ContractState, mints: Array) -> Array { + let token = IMinigameTokenDispatcher { contract_address: get_contract_address() }; + let mut token_ids: Array = array![]; + let mut index: u32 = 0; + while index < mints.len() { + let m = mints.at(index); + assert!(m.renderer_address.is_none(), "StandardGameMock: renderer not supported"); + assert!(m.skills_address.is_none(), "StandardGameMock: skills not supported"); + let context = match m.context { + Option::Some(c) => Option::Some(c.clone()), + Option::None => Option::None, + }; + let client_url = match m.client_url { + Option::Some(u) => Option::Some(u.clone()), + Option::None => Option::None, + }; + token_ids + .append( + token + .mint( + *m.player_name, + *m.settings_id, + *m.start, + *m.end, + *m.objective_id, + context, + client_url, + *m.to, + *m.soulbound, + *m.paymaster, + *m.salt, + (*m.metadata).into(), + ), + ); + index += 1; + } + token_ids + } + } + + #[abi(embed_v0)] + impl GameTokenDataImpl of IMinigameTokenData { + fn score(self: @ContractState, token_id: felt252) -> u64 { + self.scores.entry(token_id).read() + } + + fn game_over(self: @ContractState, token_id: felt252) -> bool { + self.game_over.entry(token_id).read() + } + + fn score_batch(self: @ContractState, token_ids: Span) -> Array { + let mut results = array![]; + let mut index = 0; + while index < token_ids.len() { + results.append(self.score(*token_ids.at(index))); + index += 1; + } + results + } + + fn game_over_batch(self: @ContractState, token_ids: Span) -> Array { + let mut results = array![]; + let mut index = 0; + while index < token_ids.len() { + results.append(self.game_over(*token_ids.at(index))); + index += 1; + } + results + } + } + + #[abi(embed_v0)] + impl SettingsImpl of IMinigameSettings { + fn settings_exist(self: @ContractState, settings_id: u32) -> bool { + let (_, _, exists) = self.settings_details.entry(settings_id).read(); + exists + } + + fn settings_exist_batch(self: @ContractState, settings_ids: Span) -> Array { + let mut results = array![]; + let mut index = 0; + while index < settings_ids.len() { + results.append(self.settings_exist(*settings_ids.at(index))); + index += 1; + } + results + } + } + + #[abi(embed_v0)] + impl StandardGameMockImpl of super::IStandardGameMock { + fn set_score(ref self: ContractState, token_id: felt252, score: u64) { + self.scores.entry(token_id).write(score); + } + + fn end_game(ref self: ContractState, token_id: felt252, score: u64) { + self.scores.entry(token_id).write(score); + self.game_over.entry(token_id).write(true); + } + + /// Exposes the component's internal pre-action guard for tests — + /// mirrors how a real game calls it inside its own entrypoints. + fn assert_owner_and_playable( + self: @ContractState, token_id: felt252, expected_owner: ContractAddress, + ) { + self.minigame_token.assert_owner_and_playable(token_id, expected_owner); + } + + fn create_settings_difficulty( + ref self: ContractState, name: ByteArray, description: ByteArray, difficulty: u8, + ) { + let settings_count = self.settings_count.read(); + let new_settings_id = settings_count + 1; + + self.settings_difficulty.entry(new_settings_id).write(difficulty); + self + .settings_details + .entry(new_settings_id) + .write((name.clone(), description.clone(), true)); + self.settings_count.write(new_settings_id); + + let settings = array![GameSetting { name: 'Difficulty', value: difficulty.into() }]; + + // Announce to the token — i.e. this contract. The SRC5 guard in + // the settings lib finds no IMINIGAME_TOKEN_SETTINGS_ID surface on + // the standard token and silently skips, mirroring minigame_mock's + // flow against a standard-token deployment. + self + .settings + .create_settings( + get_contract_address(), + new_settings_id, + GameSettingDetails { name, description, settings: settings.span() }, + get_contract_address(), + ); + } + } +} diff --git a/packages/utilities/src/renderer/metadata.cairo b/packages/utilities/src/renderer/metadata.cairo index ce319861..92403cff 100644 --- a/packages/utilities/src/renderer/metadata.cairo +++ b/packages/utilities/src/renderer/metadata.cairo @@ -3,7 +3,7 @@ use game_components_embeddable_game_standard::metagame::extensions::context::str use game_components_embeddable_game_standard::minigame::extensions::settings::structs::GameSettingDetails; use game_components_embeddable_game_standard::minigame::structs::GameDetail; use game_components_embeddable_game_standard::registry::interface::GameMetadata; -use game_components_embeddable_game_standard::token::structs::TokenMetadata; +use game_components_embeddable_game_standard::token_legacy::structs::TokenMetadata; use graffiti::json::JsonImpl; use starknet::{ContractAddress, get_block_timestamp}; use crate::utils::encoding::{bytes_base64_encode, felt252_to_byte_array}; diff --git a/packages/utilities/src/renderer/svg.cairo b/packages/utilities/src/renderer/svg.cairo index eff2b0b4..47f96ab0 100644 --- a/packages/utilities/src/renderer/svg.cairo +++ b/packages/utilities/src/renderer/svg.cairo @@ -5,7 +5,7 @@ use game_components_embeddable_game_standard::metagame::extensions::context::str use game_components_embeddable_game_standard::minigame::extensions::objectives::structs::GameObjectiveDetails; use game_components_embeddable_game_standard::minigame::extensions::settings::structs::GameSettingDetails; use game_components_embeddable_game_standard::registry::interface::GameMetadata; -use game_components_embeddable_game_standard::token::structs::TokenMetadata; +use game_components_embeddable_game_standard::token_legacy::structs::TokenMetadata; use starknet::get_block_timestamp; use crate::utils::encoding::felt252_to_byte_array; @@ -899,7 +899,7 @@ mod tests { GameSetting, GameSettingDetails, }; use game_components_embeddable_game_standard::registry::interface::GameMetadata; - use game_components_embeddable_game_standard::token::structs::{Lifecycle, TokenMetadata}; + use game_components_embeddable_game_standard::token_legacy::structs::{Lifecycle, TokenMetadata}; use snforge_std::{start_cheat_block_timestamp_global, stop_cheat_block_timestamp_global}; use super::{ calculate_timeline_progress, create_default_svg, timestamp_to_datetime, diff --git a/packages/utilities/src/renderer/tests/test_renderer.cairo b/packages/utilities/src/renderer/tests/test_renderer.cairo index a6f740de..6f77523c 100644 --- a/packages/utilities/src/renderer/tests/test_renderer.cairo +++ b/packages/utilities/src/renderer/tests/test_renderer.cairo @@ -15,7 +15,7 @@ use game_components_embeddable_game_standard::minigame::extensions::settings::st }; use game_components_embeddable_game_standard::minigame::structs::GameDetail; use game_components_embeddable_game_standard::registry::interface::GameMetadata; -use game_components_embeddable_game_standard::token::structs::{Lifecycle, TokenMetadata}; +use game_components_embeddable_game_standard::token_legacy::structs::{Lifecycle, TokenMetadata}; use snforge_std::{start_cheat_block_timestamp_global, stop_cheat_block_timestamp_global}; use starknet::ContractAddress; use crate::renderer::metadata::create_custom_metadata;