From ef051b802e6a25a4ee04707c09b1d2a8a01529e8 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 03:58:17 -0700 Subject: [PATCH 01/33] feat(token_lite): add single-game gas-optimized token component (denshokan lite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CoreTokenLiteComponent for single-game deployments (e.g. super-death-mountain) that never used the multi-game registry, objectives, context, skills, per-token renderers, or enumerable, and that keep game-over/objective authority in the game contract: - No mutable token state: no update_game, no metagame callbacks, no game_over latch. is_playable/assert_is_playable check the lifecycle window only — zero storage reads (pure unpack of the packed token id). - New assert_owner_and_playable merges the per-action owner_of + assert_is_playable pair into one external call. - Mint does no SRC5 probe, no registry lookup, no settings/objective validation; keeps the exact IMinigameToken::mint ABI and rejects unsupported params loudly. 251-bit pack_token_id layout is unchanged. - Registers IMINIGAME_TOKEN_LITE_ID plus the legacy IMINIGAME_TOKEN_ID and exposes a zero game_registry_address() so MinigameComponent::initializer accepts a lite token unchanged. Includes IMinigameTokenLite in the interfaces package, a wiring example contract, 30 tests, CI matrix + codecov updates (18 modules), and doc refreshes (root AGENTS.md matrix table was stale). Co-Authored-By: Claude Fable 5 --- .github/workflows/main-ci.yml | 4 + .github/workflows/pr-ci.yml | 1 + AGENTS.md | 10 +- codecov.yml | 2 +- .../embeddable_game_standard/src/lib.cairo | 1 + .../src/token_lite.cairo | 5 + .../src/token_lite/AGENTS.md | 54 ++ .../src/token_lite/interface.cairo | 5 + .../src/token_lite/tests.cairo | 4 + .../src/token_lite/tests/examples.cairo | 1 + .../tests/examples/token_lite_contract.cairo | 108 +++ .../token_lite/tests/test_token_lite.cairo | 674 ++++++++++++++++++ .../src/token_lite/token_lite_component.cairo | 338 +++++++++ packages/interfaces/src/AGENTS.md | 2 + packages/interfaces/src/token.cairo | 5 + packages/interfaces/src/token/lite.cairo | 86 +++ 16 files changed, 1295 insertions(+), 5 deletions(-) create mode 100644 packages/embeddable_game_standard/src/token_lite.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/AGENTS.md create mode 100644 packages/embeddable_game_standard/src/token_lite/interface.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/tests.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/tests/examples.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo create mode 100644 packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo create mode 100644 packages/interfaces/src/token/lite.cairo diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 5518bf49..4138d8d9 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -178,6 +178,10 @@ jobs: module: registry runner: ubuntu-latest-8 fuzzer_runs: 32 + - package: game_components_embeddable_game_standard + module: token_lite + 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..f9f3165a 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -235,6 +235,7 @@ jobs: 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_lite 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..66eae40f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,20 +131,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` | `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_lite` | `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..b85621b5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -3,7 +3,7 @@ codecov: 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 + after_n_builds: 18 comment: layout: "diff, files, header, footer" diff --git a/packages/embeddable_game_standard/src/lib.cairo b/packages/embeddable_game_standard/src/lib.cairo index 5a351bf1..26b52bde 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_lite; diff --git a/packages/embeddable_game_standard/src/token_lite.cairo b/packages/embeddable_game_standard/src/token_lite.cairo new file mode 100644 index 00000000..a192d0c5 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite.cairo @@ -0,0 +1,5 @@ +pub mod interface; + +#[cfg(test)] +mod tests; +pub mod token_lite_component; diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md new file mode 100644 index 00000000..d30bc3fc --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -0,0 +1,54 @@ +# Token Lite Module — CoreTokenLiteComponent (ERC721) + +Gas-optimized single-game variant of the `token` module ("denshokan lite"). +Built for deployments that embed exactly one game, 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. + +## Design Rules + +| Rule | Consequence | +| --- | --- | +| One game, configured at init | No registry, no `game_id` resolution, no SRC5 probes on mint | +| No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads | +| Token id layout is canonical | Reuses `token::structs::pack_token_id` (251-bit) bit-for-bit; unused fields (`game_id`, `objective_id`, `has_context`, `paymaster`, `metadata`) are written as zero | +| `mint` is ABI-compatible with `IMinigameToken::mint` | Existing call sites and the `minigame::mint` helper work unchanged; unsupported params are rejected loudly, never silently ignored | +| Game contract is the authority | Games gate dead/finished runs themselves and call `refresh_metadata` (ERC-4906) after actions | + +## Interface (IMinigameTokenLite) + +**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x3ea3d599077fbe09ddbe82ff33c1abc87aef52d8609d8bf3508fdba8dd92056` + +Defined in `packages/interfaces/src/token/lite.cairo`. The initializer also +registers `IMINIGAME_TOKEN_ID` so `MinigameComponent::initializer` (which +hard-asserts it and then queries `game_registry_address()`) accepts a lite +token; `game_registry_address()` always returns zero. + +| Method | Cost | Notes | +| --- | --- | --- | +| `mint(...)` | 1 minter-map read (warm), optional name write, ERC721 mint | Same 15-arg signature as the full token | +| `assert_owner_and_playable(token_id, expected_owner)` | 1 storage read (owner) | Combined guard — replaces `owner_of` + `assert_is_playable` (two calls) with one | +| `is_playable` / `assert_is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | +| `token_metadata`, `settings_id`, `minted_by`, `is_soulbound` | 0 storage reads | Pure unpack of the token id | +| `player_name`, `minted_by_address` | 1 storage read | | +| `refresh_metadata(_batch)` | event only | Same advisory/no-existence-check semantics as the full token | +| `update_player_name` | owner-gated write | | + +Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all other +`*_batch` views, `mint_batch_recipients`, objectives/settings/context/ +renderer/skills/enumerable surfaces. + +## Composition + +Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl +(`MinterComponent::MinterOptionalImpl` — minter ids gate reward claims in +consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` +via `unpack_soulbound` — pure, no storage). + +See `tests/examples/token_lite_contract.cairo` for a full wiring example. + +## Testing + +```bash +snforge test -p game_components_embeddable_game_standard "::token_lite::" +``` diff --git a/packages/embeddable_game_standard/src/token_lite/interface.cairo b/packages/embeddable_game_standard/src/token_lite/interface.cairo new file mode 100644 index 00000000..b27a869c --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/interface.cairo @@ -0,0 +1,5 @@ +// Re-export from interfaces package (single source of truth) +pub use game_components_interfaces::token::lite::{ + IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite, IMinigameTokenLiteDispatcher, + IMinigameTokenLiteDispatcherTrait, +}; diff --git a/packages/embeddable_game_standard/src/token_lite/tests.cairo b/packages/embeddable_game_standard/src/token_lite/tests.cairo new file mode 100644 index 00000000..f5032a5a --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/tests.cairo @@ -0,0 +1,4 @@ +// Token lite package tests + +mod examples; +mod test_token_lite; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo b/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo new file mode 100644 index 00000000..6ca18bea --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo @@ -0,0 +1 @@ +pub mod token_lite_contract; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo b/packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo new file mode 100644 index 00000000..2bb6ffcc --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo @@ -0,0 +1,108 @@ +// Example "denshokan lite" deployment: single-game ERC721 with the lite core, +// minter tracking, and a soulbound transfer guard. No registry, no enumerable, +// no objectives/context/skills/renderer extensions, no mutable token state. +// +// A production deployment would additionally override `token_uri` to call its +// game renderer contract (one stored address, one call) and add +// Ownable/Upgradeable — omitted here to keep the example focused on the +// component wiring. + +#[starknet::contract] +pub mod TokenLiteContract { + use core::num::traits::Zero; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_token::erc721::ERC721Component; + use starknet::ContractAddress; + use crate::token::extensions::minter::minter::MinterComponent; + use crate::token::structs::unpack_soulbound; + use crate::token_lite::token_lite_component::CoreTokenLiteComponent; + + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); + component!(path: MinterComponent, storage: minter, event: MinterEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + core_token_lite: CoreTokenLiteComponent::Storage, + #[substorage(v0)] + minter: MinterComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + CoreTokenLiteEvent: CoreTokenLiteComponent::Event, + #[flat] + MinterEvent: MinterComponent::Event, + } + + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + #[abi(embed_v0)] + impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + #[abi(embed_v0)] + impl CoreTokenLiteImpl = + CoreTokenLiteComponent::CoreTokenLiteImpl; + #[abi(embed_v0)] + impl MinterImpl = MinterComponent::MinterImpl; + + impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl SRC5InternalImpl = SRC5Component::InternalImpl; + impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; + impl MinterInternalImpl = MinterComponent::InternalImpl; + + // Minter is the only optional feature the lite core consumes. + impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; + + 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_address: ContractAddress, + ) { + self.erc721.initializer(name, symbol, base_uri); + self.core_token_lite.initializer(game_address); + self.minter.initializer(); + } +} diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo new file mode 100644 index 00000000..6463f3ca --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -0,0 +1,674 @@ +use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use snforge_std::{ + CheatSpan, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, + cheat_caller_address, declare, spy_events, start_cheat_block_timestamp, +}; +use starknet::ContractAddress; +use crate::token::extensions::minter::interface::{ + IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, +}; +use crate::token::interface::IMINIGAME_TOKEN_ID; +use crate::token::structs::{unpack_game_id, unpack_objective_id, unpack_token_id}; +use crate::token_lite::interface::{ + IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, +}; +use crate::token_lite::token_lite_component::CoreTokenLiteComponent; + +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} + +fn GAME() -> ContractAddress { + addr('GAME') +} + +fn ALICE() -> ContractAddress { + addr('ALICE') +} + +fn BOB() -> ContractAddress { + addr('BOB') +} + +fn MINTER() -> ContractAddress { + addr('MINTER') +} + +fn deploy_token_lite() -> ( + IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, +) { + let contract = declare("TokenLiteContract").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "LiteToken"; + let symbol: ByteArray = "LITE"; + let base_uri: ByteArray = "https://lite.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + GAME().serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + ( + IMinigameTokenLiteDispatcher { contract_address }, + ERC721ABIDispatcher { contract_address }, + IMinigameTokenMinterDispatcher { contract_address }, + ) +} + +/// Mint with lifecycle only — every unsupported parameter at its required +/// neutral value, mirroring how death-mountain-style dungeons call mint. +fn mint_basic( + token: IMinigameTokenLiteDispatcher, + player_name: Option, + settings_id: Option, + start: Option, + end: Option, + to: ContractAddress, + soulbound: bool, + salt: u16, +) -> felt252 { + token + .mint( + GAME(), + player_name, + settings_id, + start, + end, + Option::None, // objective_id + Option::None, // context + Option::None, // client_url + Option::None, // renderer_address + Option::None, // skills_address + to, + soulbound, + false, // paymaster + salt, + 0 // metadata + ) +} + +// ================================================================================================ +// DEPLOYMENT / INTERFACE REGISTRATION +// ================================================================================================ + +#[test] +fn test_deployment_and_interfaces() { + let (token, erc721, _) = deploy_token_lite(); + + assert!(token.game_address() == GAME(), "Game address should match constructor arg"); + assert!(token.game_registry_address() == addr(0), "Registry address should always be zero"); + assert!(erc721.name() == "LiteToken", "Name mismatch"); + assert!(erc721.symbol() == "LITE", "Symbol mismatch"); + + let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; + assert!(src5.supports_interface(IMINIGAME_TOKEN_LITE_ID), "Should register lite interface id"); + // Legacy id registered so MinigameComponent::initializer accepts a lite token + assert!(src5.supports_interface(IMINIGAME_TOKEN_ID), "Should register full token id"); +} + +// ================================================================================================ +// MINT — PACKED FIELDS +// ================================================================================================ + +#[test] +fn test_mint_packs_expected_fields() { + let (token, erc721, minter) = deploy_token_lite(); + 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.game_id == 0, "game_id must be 0 for single game"); + 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.objective_id == 0, "objective_id must be 0"); + assert!(packed.soulbound, "soulbound flag should be set"); + assert!(!packed.has_context, "has_context must be 0"); + assert!(!packed.paymaster, "paymaster must be 0"); + assert!(packed.salt == 7, "salt mismatch"); + assert!(packed.metadata == 0, "metadata must be 0"); + + // 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_lite(); + 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"); + assert!(token.player_name(token_id) == 0, "No player name set"); +} + +#[test] +fn test_mint_past_start_clamps_to_now() { + let (token, _, _) = deploy_token_lite(); + 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_lite(); + 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 — REJECTED PARAMETERS +// ================================================================================================ + +#[test] +#[should_panic(expected: "MinigameTokenLite: objectives not supported")] +fn test_mint_rejects_objective_id() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(1), + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: context not supported")] +fn test_mint_rejects_context() { + let (token, _, _) = deploy_token_lite(); + let context = crate::token::structs::GameContextDetails { + name: "ctx", description: "ctx", id: Option::None, context: array![].span(), + }; + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(context), + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: client_url not supported")] +fn test_mint_rejects_client_url() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some("https://x.test"), + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: per-token renderer not supported")] +fn test_mint_rejects_renderer() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(addr('RENDERER')), + Option::None, + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: skills not supported")] +fn test_mint_rejects_skills() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(addr('SKILLS')), + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: paymaster flag not supported")] +fn test_mint_rejects_paymaster() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + true, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: metadata field not supported")] +fn test_mint_rejects_metadata() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 5, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Game address does not match configured game")] +fn test_mint_rejects_wrong_game_address() { + let (token, _, _) = deploy_token_lite(); + token + .mint( + addr('OTHER_GAME'), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 0, + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Lifecycle end must be in the future and after start")] +fn test_mint_rejects_past_end() { + let (token, _, _) = deploy_token_lite(); + 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_lite(); + 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_lite(); + 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"); + token.assert_is_playable(token_id); + token.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_lite(); + 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"); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has expired")] +fn test_assert_is_playable_panics_after_expiry() { + let (token, _, _) = deploy_token_lite(); + 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); + token.assert_is_playable(token_id); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has not started")] +fn test_assert_is_playable_panics_before_start() { + let (token, _, _) = deploy_token_lite(); + 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, + ); + token.assert_is_playable(token_id); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] +fn test_assert_owner_and_playable_rejects_wrong_owner() { + let (token, _, _) = deploy_token_lite(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + token.assert_owner_and_playable(token_id, BOB()); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] +fn test_assert_owner_and_playable_rejects_nonexistent_token() { + let (token, _, _) = deploy_token_lite(); + token.assert_owner_and_playable(12345, ALICE()); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Expected owner cannot be zero")] +fn test_assert_owner_and_playable_rejects_zero_owner() { + let (token, _, _) = deploy_token_lite(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + 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_lite(); + 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_lite(); + 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_lite(); + 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, + CoreTokenLiteComponent::Event::MetadataUpdate( + CoreTokenLiteComponent::MetadataUpdate { token_id: token_id.into() }, + ), + ), + ], + ); +} + +#[test] +fn test_refresh_metadata_batch_emits_events() { + let (token, _, _) = deploy_token_lite(); + 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, + ); + + let mut spy = spy_events(); + token.refresh_metadata_batch(array![id_a, id_b].span()); + spy + .assert_emitted( + @array![ + ( + token.contract_address, + CoreTokenLiteComponent::Event::MetadataUpdate( + CoreTokenLiteComponent::MetadataUpdate { token_id: id_a.into() }, + ), + ), + ( + token.contract_address, + CoreTokenLiteComponent::Event::MetadataUpdate( + CoreTokenLiteComponent::MetadataUpdate { token_id: id_b.into() }, + ), + ), + ], + ); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: token_ids array cannot be empty")] +fn test_refresh_metadata_batch_rejects_empty() { + let (token, _, _) = deploy_token_lite(); + token.refresh_metadata_batch(array![].span()); +} + +#[test] +fn test_update_player_name_by_owner() { + let (token, _, _) = deploy_token_lite(); + 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: "MinigameTokenLite: Caller is not owner of token")] +fn test_update_player_name_rejects_non_owner() { + let (token, _, _) = deploy_token_lite(); + 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'); +} + +// ================================================================================================ +// PACKING PARITY HELPERS +// ================================================================================================ + +#[test] +fn test_helper_unpackers_agree_with_full_unpack() { + let (token, _, _) = deploy_token_lite(); + start_cheat_block_timestamp(token.contract_address, 1234); + let token_id = mint_basic( + token, Option::None, Option::Some(9), Option::None, Option::Some(9999), ALICE(), true, 3, + ); + + // The lite token reuses the canonical 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_game_id(token_id) == packed.game_id, "game_id helper mismatch"); + assert!(unpack_objective_id(token_id) == packed.objective_id, "objective helper mismatch"); + assert!(packed.game_id == 0 && packed.objective_id == 0, "lite invariants"); +} diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo new file mode 100644 index 00000000..a9c67b35 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -0,0 +1,338 @@ +/// # CoreTokenLiteComponent +/// +/// Single-game, storage-minimal variant of `CoreTokenComponent`, built for +/// deployments (e.g. death-mountain-style dungeons) that never used the +/// multi-game registry, objectives, context, skills, per-token renderers or +/// client urls, and that keep game-over / objective completion authority in +/// the game contract itself. +/// +/// What is deliberately gone, and why it is safe to remove: +/// * **Registry** — one game, stored once in `game_address`. No +/// `game_id_from_address` on mint, no `game_address_from_id` anywhere. +/// * **Mutable token state** — no `game_over`/`completed_objective` latch. +/// The game contract is the sole authority; playability here is the +/// lifecycle window only, which lives packed inside the token id, so +/// `is_playable` costs zero storage reads. +/// * **`update_game` + metagame callbacks** — nothing to sync and nobody to +/// notify. `refresh_metadata` (ERC-4906) is the only post-action hook. +/// * **SRC5 round-trips** — the game address is trusted at initialization; +/// mint performs no `supports_interface` calls. +/// * **Settings/objective validation on mint** — minters pass an +/// admin-configured `settings_id`; the game validates it at play time. +/// +/// What is kept bit-identical: the 251-bit `pack_token_id` layout. Existing +/// integrations unpack `settings_id`/`minted_by`/lifecycle from the id and +/// indexers decode it; the lite token writes zeros into `game_id`, +/// `objective_id`, `has_context`, `paymaster` and `metadata` rather than +/// reshuffling bits. +#[starknet::component] +pub mod CoreTokenLiteComponent { + use core::num::traits::Zero; + use game_components_interfaces::token::lite::{IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite}; + 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::interface::IMINIGAME_TOKEN_ID; + use crate::token::structs::{ + GameContextDetails, TokenMetadata, TokenMutableState, extract_tx_hash_bits, pack_token_id, + to_token_metadata, unpack_minted_by, unpack_settings_id, unpack_soulbound, unpack_token_id, + }; + use crate::token::token::{LifecycleTrait, token_state}; + use crate::token::traits::OptionalMinter; + + #[storage] + pub struct Storage { + game_address: ContractAddress, + token_player_names: Map, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + MetadataUpdate: MetadataUpdate, + } + + /// ERC-4906 standard metadata update event + #[derive(Drop, starknet::Event)] + pub struct MetadataUpdate { + #[key] + pub token_id: u256, + } + + #[embeddable_as(CoreTokenLiteImpl)] + pub impl CoreTokenLite< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + impl MinterOpt: OptionalMinter, + +Drop, + +ERC721Component::ERC721HooksTrait, + > of IMinigameTokenLite> { + fn token_metadata( + self: @ComponentState, token_id: felt252, + ) -> TokenMetadata { + let packed = unpack_token_id(token_id); + // No mutable state exists; the game contract is authoritative for + // game_over / objective completion. + let empty_state = TokenMutableState { + game_over: false, completed_objective: false, completed_at: 0, + }; + to_token_metadata(packed, empty_state) + } + + fn is_playable(self: @ComponentState, token_id: felt252) -> bool { + let metadata = self.token_metadata(token_id); + metadata.lifecycle.is_playable(get_block_timestamp()) + } + + fn assert_is_playable(self: @ComponentState, token_id: felt252) { + self.assert_lifecycle_open(token_id); + } + + fn assert_owner_and_playable( + self: @ComponentState, + token_id: felt252, + expected_owner: ContractAddress, + ) { + assert!(!expected_owner.is_zero(), "MinigameTokenLite: 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, + "MinigameTokenLite: Address is not owner of token {}", + token_id, + ); + self.assert_lifecycle_open(token_id); + } + + 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); + let contract_ref = self.get_contract(); + MinterOpt::get_minter_address(contract_ref, minted_by_id) + } + + fn is_soulbound(self: @ComponentState, token_id: felt252) -> bool { + unpack_soulbound(token_id) + } + + fn game_address(self: @ComponentState) -> ContractAddress { + self.game_address.read() + } + + fn game_registry_address(self: @ComponentState) -> ContractAddress { + // Compat shim: MinigameComponent::initializer queries this before + // deciding whether to register with a registry. Zero = no registry. + Zero::zero() + } + + fn mint( + 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, + to: ContractAddress, + soulbound: bool, + paymaster: bool, + salt: u16, + metadata: u16, + ) -> felt252 { + // The signature matches IMinigameToken::mint so existing call + // sites work unchanged, but unsupported features must not be + // silently dropped — reject them loudly. + assert!(objective_id.is_none(), "MinigameTokenLite: objectives not supported"); + assert!(context.is_none(), "MinigameTokenLite: context not supported"); + assert!(client_url.is_none(), "MinigameTokenLite: client_url not supported"); + assert!( + renderer_address.is_none(), "MinigameTokenLite: per-token renderer not supported", + ); + assert!(skills_address.is_none(), "MinigameTokenLite: skills not supported"); + assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); + assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); + + // Single game — no SRC5 probe, no registry resolution. The + // parameter is kept (and checked) purely for call-site parity. + assert!( + game_address == self.game_address.read(), + "MinigameTokenLite: Game address does not match configured game", + ); + + 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), + "MinigameTokenLite: 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 mut contract_self = self.get_contract_mut(); + let minted_by = MinterOpt::add_minter(ref contract_self, caller); + + let final_token_id = pack_token_id( + 0, // game_id: always 0 — single game + minted_by, + settings_id.unwrap_or(0), + current_time, + start_delay, + end_delay, + 0, // objective_id + soulbound, + false, // has_context + false, // paymaster + tx_hash_bits, + salt, + 0 // metadata + ); + + if let Option::Some(name) = player_name { + self.token_player_names.entry(final_token_id).write(name); + } + + 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 + } + + /// 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 refresh_metadata_batch( + ref self: ComponentState, token_ids: Span, + ) { + assert!(token_ids.len() > 0, "MinigameTokenLite: token_ids array cannot be empty"); + let mut i: u32 = 0; + while i < token_ids.len() { + self.emit(MetadataUpdate { token_id: (*token_ids.at(i)).into() }); + i += 1; + } + } + + fn update_player_name( + ref self: ComponentState, token_id: felt252, name: felt252, + ) { + assert!(!name.is_zero(), "MinigameTokenLite: 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(), + "MinigameTokenLite: Caller is not owner of token", + ); + self.token_player_names.entry(token_id).write(name); + self.emit(MetadataUpdate { token_id: token_id.into() }); + } + } + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + impl ERC721: ERC721Component::HasComponent, + impl MinterOpt: OptionalMinter, + +Drop, + +ERC721Component::ERC721HooksTrait, + > of InternalTrait { + fn initializer(ref self: ComponentState, game_address: ContractAddress) { + assert!(!game_address.is_zero(), "MinigameTokenLite: Game address is zero"); + self.game_address.write(game_address); + + let mut contract = self.get_contract_mut(); + let mut src5_component = SRC5::get_component_mut(ref contract); + src5_component.register_interface(IMINIGAME_TOKEN_LITE_ID); + // Also advertise the full-token id: MinigameComponent::initializer + // hard-asserts it before wiring a game to its token. The lite + // token implements the subset of IMinigameToken that game-side + // components actually call (mint, assert_is_playable, player_name, + // refresh_metadata, game_registry_address); anything else reverts + // with ENTRYPOINT_NOT_FOUND rather than misbehaving silently. + src5_component.register_interface(IMINIGAME_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 packed = unpack_token_id(token_id); + let empty_state = TokenMutableState { + game_over: false, completed_objective: false, completed_at: 0, + }; + let metadata = to_token_metadata(packed, empty_state); + let current_time = get_block_timestamp(); + let lifecycle = metadata.lifecycle; + assert!( + lifecycle.can_start(current_time), + "MinigameTokenLite: Token is not playable - game has not started (now={}, start={})", + current_time, + lifecycle.start, + ); + assert!( + !lifecycle.has_expired(current_time), + "MinigameTokenLite: Token is not playable - game has expired (now={}, end={})", + current_time, + lifecycle.end, + ); + } + } +} diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index bc86a7fe..6b2a00ae 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -9,6 +9,7 @@ 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/lite` | `IMinigameTokenLite` | Single-game gas-optimized token (no registry, no mutable state) | | `registry` | `IMinigameRegistry` | Game registration and metadata lookup | | `leaderboard` | `ILeaderboard`, `ILeaderboardAdmin`, `IGameDetails` | Tournament scoring and rankings | | `tokenomics/buyback` | `IBuyback`, `IBuybackAdmin` | Autonomous buyback via Ekubo TWAMM | @@ -33,6 +34,7 @@ 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_LITE_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/token.cairo b/packages/interfaces/src/token.cairo index 9638fb59..66d618a8 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -2,6 +2,7 @@ pub mod context; pub mod core; +pub mod lite; pub mod minter; pub mod objectives; pub mod renderer; @@ -13,6 +14,10 @@ pub use context::IMINIGAME_TOKEN_CONTEXT_ID; pub use core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; +pub use lite::{ + IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite, IMinigameTokenLiteDispatcher, + IMinigameTokenLiteDispatcherTrait, +}; pub use minter::{ IMINIGAME_TOKEN_MINTER_ID, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo new file mode 100644 index 00000000..e2cbf99d --- /dev/null +++ b/packages/interfaces/src/token/lite.cairo @@ -0,0 +1,86 @@ +// Lite token interface — single-game, no mutable token state. +// +// Gas-optimized subset of `IMinigameToken` for deployments that embed exactly one +// game and let the game contract remain the sole authority on game-over / +// objective completion. The token stores no per-token mutable state: everything +// except `player_name` is unpacked from the token id itself, so every view is +// pure felt arithmetic plus at most one storage read. +// +// `mint` keeps the exact `IMinigameToken::mint` signature (same selector, same +// calldata layout) so existing call sites and the `minigame::mint` helper work +// unchanged against a lite deployment. Parameters the lite token does not +// support (objective_id, context, client_url, renderer_address, skills_address, +// paymaster, metadata) must be passed as `None`/`false`/`0` — the +// implementation rejects anything else loudly rather than silently ignoring it. +// +// Semantics that differ from the full token: +// * `is_playable`/`assert_is_playable` check 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. +// * 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::TokenMetadata; + +/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. +/// +/// Surface is the trait below minus `refresh_metadata`/`refresh_metadata_batch`, +/// mirroring their exclusion from `IMINIGAME_TOKEN_ID`. Run `src5_rs parse` +/// against a stripped copy of this trait (see packages/interfaces/src/AGENTS.md) +/// to rederive. +pub const IMINIGAME_TOKEN_LITE_ID: felt252 = + 0x3ea3d599077fbe09ddbe82ff33c1abc87aef52d8609d8bf3508fdba8dd92056; + +#[starknet::interface] +pub trait IMinigameTokenLite { + 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); + /// Combined ownership + playability guard: one external call instead of + /// `owner_of` followed by `assert_is_playable`. `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: @TState, token_id: felt252, expected_owner: ContractAddress); + 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 game_address(self: @TState) -> ContractAddress; + /// Always returns the zero address — the lite token has no registry. Kept + /// so `MinigameComponent::initializer`, which unconditionally queries the + /// registry address before deciding whether to register the game, works + /// against a lite deployment without modification. + fn game_registry_address(self: @TState) -> ContractAddress; + + /// Signature-compatible with `IMinigameToken::mint`. `game_address` must be + /// the single configured game; `objective_id`, `context`, `client_url`, + /// `renderer_address`, `skills_address` must be `None`, `paymaster` must be + /// `false`, and `metadata` must be `0`. + 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; + /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see + /// `IMinigameToken::refresh_metadata` for the spam/existence trade-offs; + /// identical semantics here. + fn refresh_metadata(ref self: TState, token_id: felt252); + fn refresh_metadata_batch(ref self: TState, token_ids: Span); + fn update_player_name(ref self: TState, token_id: felt252, name: felt252); +} From 135540ffb47f53da4603b9949b722f7361b6cb3b Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:34:35 -0700 Subject: [PATCH 02/33] test(token_lite): add paired gas benchmarks vs full token component Baseline/op-x10 test pairs measuring warm mint, per-action ownership+ playability guard, and post-action sync on the lite component against FullTokenContract in its deployed-denshokan configuration (registry-backed multi-game, all extensions). Co-Authored-By: Claude Fable 5 --- .../src/token_lite/tests.cairo | 1 + .../src/token_lite/tests/test_gas_bench.cairo | 280 ++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo diff --git a/packages/embeddable_game_standard/src/token_lite/tests.cairo b/packages/embeddable_game_standard/src/token_lite/tests.cairo index f5032a5a..d1e2ba0f 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests.cairo @@ -1,4 +1,5 @@ // Token lite package tests mod examples; +mod test_gas_bench; mod test_token_lite; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo new file mode 100644 index 00000000..13886822 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo @@ -0,0 +1,280 @@ +// Gas benchmarks: CoreTokenLiteComponent vs the full 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 full-token numbers. + +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_lite::interface::{IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait}; + +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 +} + +fn setup_lite() -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, ContractAddress) { + let game = deploy_mock_game(); + let contract = declare("TokenLiteContract").unwrap().contract_class(); + let mut calldata: Array = array![]; + let name: ByteArray = "LiteToken"; + let symbol: ByteArray = "LITE"; + let base_uri: ByteArray = "https://lite.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + game.serialize(ref calldata); + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + start_cheat_block_timestamp(contract_address, START_TIME); + ( + IMinigameTokenLiteDispatcher { contract_address }, + ERC721ABIDispatcher { contract_address }, + game, + ) +} + +/// Full token in the deployed-denshokan shape: multi-game registry with the +/// mock game registered, all optional extensions compiled in. +fn setup_full() -> (IMinigameTokenDispatcher, 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); + ( + IMinigameTokenDispatcher { contract_address: token_address }, + ERC721ABIDispatcher { contract_address: token_address }, + game, + ) +} + +fn mint_lite(token: IMinigameTokenLiteDispatcher, 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, + ) +} + +fn mint_full(token: IMinigameTokenDispatcher, 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_lite_deploy_baseline() { + let (_, _, _) = setup_lite(); +} + +#[test] +fn bench_full_deploy_baseline() { + let (_, _, _) = setup_full(); +} + +// ================================================================================================ +// MINT — first mint (x1, cold minter registration) and x10 (9 warm mints) +// ================================================================================================ + +#[test] +fn bench_lite_mint_x1() { + let (token, _, game) = setup_lite(); + mint_lite(token, game, 0); +} + +#[test] +fn bench_full_mint_x1() { + let (token, _, game) = setup_full(); + mint_full(token, game, 0); +} + +#[test] +fn bench_lite_mint_x10() { + let (token, _, game) = setup_lite(); + let mut salt: u16 = 0; + while salt < 10 { + mint_lite(token, game, salt); + salt += 1; + } +} + +#[test] +fn bench_full_mint_x10() { + let (token, _, game) = setup_full(); + let mut salt: u16 = 0; + while salt < 10 { + mint_full(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 lite: assert_owner_and_playable (1) +// ================================================================================================ + +#[test] +fn bench_lite_guard_x10() { + let (token, _, game) = setup_lite(); + let token_id = mint_lite(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + token.assert_owner_and_playable(token_id, ALICE()); + i += 1; + } +} + +#[test] +fn bench_full_guard_x10() { + let (token, erc721, game) = setup_full(); + let token_id = mint_full(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 lite: refresh_metadata (event only) +// ================================================================================================ + +#[test] +fn bench_lite_post_action_x10() { + let (token, _, game) = setup_lite(); + let token_id = mint_lite(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + token.refresh_metadata(token_id); + i += 1; + } +} + +#[test] +fn bench_full_post_action_x10() { + let (token, _, game) = setup_full(); + let token_id = mint_full(token, game, 0); + let mut i: u32 = 0; + while i < 10 { + token.update_game(token_id); + i += 1; + } +} From 528c056a4a18b7dec57c069996d1b985dd5c95d5 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:25:43 -0700 Subject: [PATCH 03/33] feat(minigame): add lite pre_action/post_action call-site twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Free-function helpers in minigame::lite keep game code's familiar shape against a lite token — the module path carries the semantic shift: pre_action folds the assert_token_ownership + pre_action pair into one assert_owner_and_playable call; post_action is refresh_metadata only. Co-Authored-By: Claude Fable 5 --- .../src/minigame.cairo | 1 + .../src/minigame/lite.cairo | 35 ++++++++++ .../src/token_lite/AGENTS.md | 10 +++ .../token_lite/tests/test_token_lite.cairo | 65 +++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 packages/embeddable_game_standard/src/minigame/lite.cairo diff --git a/packages/embeddable_game_standard/src/minigame.cairo b/packages/embeddable_game_standard/src/minigame.cairo index f4d3c3c8..119928f7 100644 --- a/packages/embeddable_game_standard/src/minigame.cairo +++ b/packages/embeddable_game_standard/src/minigame.cairo @@ -1,5 +1,6 @@ pub mod extensions; pub mod interface; +pub mod lite; pub mod minigame; pub mod minigame_component; pub mod structs; diff --git a/packages/embeddable_game_standard/src/minigame/lite.cairo b/packages/embeddable_game_standard/src/minigame/lite.cairo new file mode 100644 index 00000000..777d07f2 --- /dev/null +++ b/packages/embeddable_game_standard/src/minigame/lite.cairo @@ -0,0 +1,35 @@ +// Lite-token twins of `minigame::minigame::pre_action` / `post_action`. +// +// Same call-site shape as the full-token helpers, different token contract and +// deliberately different semantics — the module path is what signals the shift: +// +// * `pre_action` folds the old `assert_token_ownership` + `pre_action` pair +// into ONE cross-contract call. The lite token checks that this contract's +// caller owns the token and that the lifecycle window is open. There is no +// token-side game_over/objective latch to consult — with the lite token the +// game contract is the sole authority on those, and must gate finished runs +// itself. +// * `post_action` emits an ERC-4906 refresh and nothing else. There is no +// `update_game` on the lite token and no state to sync back. +// +// Like the full-token helpers, these are free functions that run in the game +// contract's own execution context, so `get_caller_address()` inside +// `pre_action` is the game's caller (the player). +use starknet::{ContractAddress, get_caller_address}; +use crate::token_lite::interface::{IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait}; + +/// Asserts the game's caller owns `token_id` and its lifecycle window is open. +/// One external call — replaces the full-token `assert_token_ownership` + +/// `pre_action` pair. +pub fn pre_action(minigame_token_address: ContractAddress, token_id: felt252) { + IMinigameTokenLiteDispatcher { contract_address: minigame_token_address } + .assert_owner_and_playable(token_id, get_caller_address()); +} + +/// Emits a state-free ERC-4906 `MetadataUpdate` for `token_id` so indexers and +/// marketplaces observe the action. Writes nothing; safe to call while the +/// roll's outcome is sealed. +pub fn post_action(minigame_token_address: ContractAddress, token_id: felt252) { + IMinigameTokenLiteDispatcher { contract_address: minigame_token_address } + .refresh_metadata(token_id); +} diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index d30bc3fc..a3543700 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -38,6 +38,16 @@ Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all other `*_batch` views, `mint_batch_recipients`, objectives/settings/context/ renderer/skills/enumerable surfaces. +## Game-side helpers + +`minigame::lite` provides call-site twins of the full-token helpers so game +code keeps its familiar shape — the module path carries the semantic shift: + +- `lite::pre_action(token_address, token_id)` → one `assert_owner_and_playable` + call (replaces the full-token `assert_token_ownership` + `pre_action` pair) +- `lite::post_action(token_address, token_id)` → `refresh_metadata` only + (there is no `update_game` to run) + ## Composition Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index 6463f3ca..3f5db4b6 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -652,6 +652,71 @@ fn test_update_player_name_rejects_non_owner() { token.update_player_name(token_id, 'new'); } +// ================================================================================================ +// MINIGAME LITE HELPERS (minigame::lite::pre_action / post_action) +// ================================================================================================ +// +// The helpers are free functions that run in the calling contract's execution +// context: `get_caller_address()` inside `pre_action` is whoever called the +// game contract. The tests model that by cheating the caller of the test +// contract itself (`snforge_std::test_address()`), which plays the game's role. + +#[test] +fn test_lite_pre_action_passes_for_owner_caller() { + let (token, _, _) = deploy_token_lite(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + snforge_std::start_cheat_caller_address(snforge_std::test_address(), ALICE()); + crate::minigame::lite::pre_action(token.contract_address, token_id); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] +fn test_lite_pre_action_rejects_non_owner_caller() { + let (token, _, _) = deploy_token_lite(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + snforge_std::start_cheat_caller_address(snforge_std::test_address(), BOB()); + crate::minigame::lite::pre_action(token.contract_address, token_id); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has expired")] +fn test_lite_pre_action_rejects_expired_token() { + let (token, _, _) = deploy_token_lite(); + 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); + snforge_std::start_cheat_caller_address(snforge_std::test_address(), ALICE()); + crate::minigame::lite::pre_action(token.contract_address, token_id); +} + +#[test] +fn test_lite_post_action_emits_refresh() { + let (token, _, _) = deploy_token_lite(); + let token_id = mint_basic( + token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, + ); + + let mut spy = spy_events(); + crate::minigame::lite::post_action(token.contract_address, token_id); + spy + .assert_emitted( + @array![ + ( + token.contract_address, + CoreTokenLiteComponent::Event::MetadataUpdate( + CoreTokenLiteComponent::MetadataUpdate { token_id: token_id.into() }, + ), + ), + ], + ); +} + // ================================================================================================ // PACKING PARITY HELPERS // ================================================================================================ From 62dc47bf198a95cb8e4721890a086ff032a21cb7 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:58:11 -0700 Subject: [PATCH 04/33] feat(token_lite): batch mint, registry-less game check, shared example Stage 0 for metagame (tournament-platform) compatibility with lite tokens: - mint_batch_recipients on CoreTokenLiteComponent, ABI-compatible with the full token (same global salt counter, salt + sum(counts) - 1 <= 0x3FF); batch work hoisted, unsupported params rejected like mint. Lite interface id rederived to include it. - metagame::metagame::assert_game_registered now accepts registry-less tokens: when game_registry_address() is zero (single-game full tokens and lite tokens), registered means the game <-> token pairing is mutual. Previously this path dispatched to address 0 and reverted. - Deployable TokenLiteContract example moved to test_common so downstream suites can declare it via build-external-contracts; embeddable_game_standard tests now consume it from there. End-to-end test covers MinigameComponent::initializer + assert_game_registered against a lite token. Co-Authored-By: Claude Fable 5 --- packages/embeddable_game_standard/Scarb.toml | 1 + .../src/metagame/metagame.cairo | 13 +- .../src/token_lite.cairo | 2 + .../src/token_lite/AGENTS.md | 16 +- .../src/token_lite/tests.cairo | 4 +- .../src/token_lite/tests/examples.cairo | 1 - .../token_lite/tests/test_token_lite.cairo | 207 +++++++++++++++++- .../src/token_lite/token_lite_component.cairo | 143 +++++++++++- packages/interfaces/src/token/lite.cairo | 26 ++- packages/test_common/src/examples.cairo | 1 + .../src}/examples/token_lite_contract.cairo | 9 +- 11 files changed, 403 insertions(+), 20 deletions(-) delete mode 100644 packages/embeddable_game_standard/src/token_lite/tests/examples.cairo rename packages/{embeddable_game_standard/src/token_lite/tests => test_common/src}/examples/token_lite_contract.cairo (89%) diff --git a/packages/embeddable_game_standard/Scarb.toml b/packages/embeddable_game_standard/Scarb.toml index c3f253ff..6285f759 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::examples::token_lite_contract::TokenLiteContract", ] diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index b4465212..9e309cee 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -1,3 +1,4 @@ +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, @@ -13,8 +14,14 @@ use crate::metagame::structs::MintMetagameParams; /// Asserts that a game is registered in the minigame token contract /// +/// For registry-backed (multi-game) tokens this asks the registry. For tokens +/// with no registry — single-game full tokens and lite tokens both report a +/// zero `game_registry_address()` — "registered" means the pairing is mutual: +/// the game names this token, and the token's one configured game is this +/// game. Previously this path dispatched to the zero address and reverted +/// with CONTRACT_NOT_DEPLOYED for any single-game token. +/// /// # 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 }; @@ -23,6 +30,10 @@ pub fn assert_game_registered(game_address: ContractAddress) { 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, }; diff --git a/packages/embeddable_game_standard/src/token_lite.cairo b/packages/embeddable_game_standard/src/token_lite.cairo index a192d0c5..19cf8800 100644 --- a/packages/embeddable_game_standard/src/token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite.cairo @@ -1,5 +1,7 @@ pub mod interface; +// The deployable example (TokenLiteContract) lives in the test_common package +// so downstream consumers can declare it via build-external-contracts. #[cfg(test)] mod tests; pub mod token_lite_component; diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index a3543700..71a93f35 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -17,7 +17,7 @@ game-over / objective-completion authority in the game contract itself. ## Interface (IMinigameTokenLite) -**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x3ea3d599077fbe09ddbe82ff33c1abc87aef52d8609d8bf3508fdba8dd92056` +**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7` Defined in `packages/interfaces/src/token/lite.cairo`. The initializer also registers `IMINIGAME_TOKEN_ID` so `MinigameComponent::initializer` (which @@ -27,6 +27,7 @@ token; `game_registry_address()` always returns zero. | Method | Cost | Notes | | --- | --- | --- | | `mint(...)` | 1 minter-map read (warm), optional name write, ERC721 mint | Same 15-arg signature as the full token | +| `mint_batch_recipients(...)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | ABI-compatible with the full token; same global salt counter (`salt + sum(counts) - 1 <= 0x3FF`) | | `assert_owner_and_playable(token_id, expected_owner)` | 1 storage read (owner) | Combined guard — replaces `owner_of` + `assert_is_playable` (two calls) with one | | `is_playable` / `assert_is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | | `token_metadata`, `settings_id`, `minted_by`, `is_soulbound` | 0 storage reads | Pure unpack of the token id | @@ -34,9 +35,8 @@ token; `game_registry_address()` always returns zero. | `refresh_metadata(_batch)` | event only | Same advisory/no-existence-check semantics as the full token | | `update_player_name` | owner-gated write | | -Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all other -`*_batch` views, `mint_batch_recipients`, objectives/settings/context/ -renderer/skills/enumerable surfaces. +Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all batch +views, objectives/settings/context/renderer/skills/enumerable surfaces. ## Game-side helpers @@ -55,7 +55,13 @@ Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` via `unpack_soulbound` — pure, no storage). -See `tests/examples/token_lite_contract.cairo` for a full wiring example. +See `test_common/src/examples/token_lite_contract.cairo` for a full wiring +example — it lives in the test_common package so downstream consumers can +declare `TokenLiteContract` in their own suites via `build-external-contracts`. + +For metagames: `metagame::metagame::assert_game_registered` accepts +registry-less tokens (zero `game_registry_address()`) by asserting the mutual +game ↔ token pairing instead of dispatching to the registry. ## Testing diff --git a/packages/embeddable_game_standard/src/token_lite/tests.cairo b/packages/embeddable_game_standard/src/token_lite/tests.cairo index d1e2ba0f..1d6fb7ef 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests.cairo @@ -1,5 +1,7 @@ // Token lite package tests +// +// The deployable example contract (TokenLiteContract) is declared from +// game_components_test_common::examples via build-external-contracts. -mod examples; mod test_gas_bench; mod test_token_lite; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo b/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo deleted file mode 100644 index 6ca18bea..00000000 --- a/packages/embeddable_game_standard/src/token_lite/tests/examples.cairo +++ /dev/null @@ -1 +0,0 @@ -pub mod token_lite_contract; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index 3f5db4b6..b73e96a4 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -1,3 +1,4 @@ +use game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcherTrait; use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use snforge_std::{ @@ -9,7 +10,9 @@ use crate::token::extensions::minter::interface::{ IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, }; use crate::token::interface::IMINIGAME_TOKEN_ID; -use crate::token::structs::{unpack_game_id, unpack_objective_id, unpack_token_id}; +use crate::token::structs::{ + MintBatchRecipient, unpack_game_id, unpack_objective_id, unpack_salt, unpack_token_id, +}; use crate::token_lite::interface::{ IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, }; @@ -35,9 +38,9 @@ fn MINTER() -> ContractAddress { addr('MINTER') } -fn deploy_token_lite() -> ( - IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, -) { +fn deploy_token_lite_for_game( + game_address: ContractAddress, +) -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher) { let contract = declare("TokenLiteContract").unwrap().contract_class(); let mut calldata: Array = array![]; let name: ByteArray = "LiteToken"; @@ -46,7 +49,7 @@ fn deploy_token_lite() -> ( name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - GAME().serialize(ref calldata); + game_address.serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); ( IMinigameTokenLiteDispatcher { contract_address }, @@ -55,6 +58,12 @@ fn deploy_token_lite() -> ( ) } +fn deploy_token_lite() -> ( + IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, +) { + deploy_token_lite_for_game(GAME()) +} + /// Mint with lifecycle only — every unsupported parameter at its required /// neutral value, mirroring how death-mountain-style dungeons call mint. fn mint_basic( @@ -652,6 +661,119 @@ fn test_update_player_name_rejects_non_owner() { token.update_player_name(token_id, 'new'); } +// ================================================================================================ +// BATCH MINT +// ================================================================================================ + +fn batch_neutral( + token: IMinigameTokenLiteDispatcher, recipients: Array, salt: u16, +) -> Array { + token + .mint_batch_recipients( + GAME(), + Option::Some('bench'), + Option::Some(5), + Option::None, + Option::None, + 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_lite(); + 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: "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 1023)", +)] +fn test_mint_batch_recipients_rejects_salt_overflow() { + let (token, _, _) = deploy_token_lite(); + batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 4 }], 1021); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: recipients array cannot be empty")] +fn test_mint_batch_recipients_rejects_empty() { + let (token, _, _) = deploy_token_lite(); + batch_neutral(token, array![], 0); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: per-recipient count must be > 0")] +fn test_mint_batch_recipients_rejects_zero_count() { + let (token, _, _) = deploy_token_lite(); + batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 0 }], 0); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: context not supported")] +fn test_mint_batch_recipients_rejects_context() { + let (token, _, _) = deploy_token_lite(); + let context = crate::token::structs::GameContextDetails { + name: "ctx", description: "ctx", id: Option::None, context: array![].span(), + }; + token + .mint_batch_recipients( + GAME(), + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::Some(context), + Option::None, + Option::None, + Option::None, + array![MintBatchRecipient { to: ALICE(), count: 1 }], + false, + false, + 0, + 0, + ); +} + // ================================================================================================ // MINIGAME LITE HELPERS (minigame::lite::pre_action / post_action) // ================================================================================================ @@ -717,6 +839,81 @@ fn test_lite_post_action_emits_refresh() { ); } +// ================================================================================================ +// GAME-SIDE INTEGRATION (MinigameComponent + metagame assert_game_registered) +// ================================================================================================ + +fn deploy_initialized_minigame_mock(token_address: ContractAddress) -> ContractAddress { + let contract = declare("minigame_mock").unwrap().contract_class(); + let (game_address, _) = contract.deploy(@array![]).unwrap(); + game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcher { + contract_address: game_address, + } + .initializer( + ALICE(), + "Game", + "d", + "dev", + "pub", + "genre", + "img", + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + token_address, + Option::None, + ); + game_address +} + +/// End-to-end: MinigameComponent::initializer SRC5-checks the token for +/// IMINIGAME_TOKEN_ID and queries game_registry_address(); the lite token's +/// legacy-id registration and zero-registry shim must satisfy both, and the +/// metagame lib must then treat the mutual game <-> token pairing as +/// registered. +#[test] +fn test_minigame_initializer_and_game_registered_with_lite_token() { + let game_class = declare("minigame_mock").unwrap().contract_class(); + let (game_address, _) = game_class.deploy(@array![]).unwrap(); + let (token, _, _) = deploy_token_lite_for_game(game_address); + game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcher { + contract_address: game_address, + } + .initializer( + ALICE(), + "Game", + "d", + "dev", + "pub", + "genre", + "img", + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + token.contract_address, + Option::None, + ); + + crate::metagame::metagame::assert_game_registered(game_address); +} + +#[test] +#[should_panic(expected: "Game is not registered")] +fn test_assert_game_registered_rejects_game_not_paired_with_lite_token() { + let game_class = declare("minigame_mock").unwrap().contract_class(); + let (game_a, _) = game_class.deploy(@array![]).unwrap(); + let (token, _, _) = deploy_token_lite_for_game(game_a); + + // A second game pointing at the same lite token: the token's one + // configured game is game_a, so game_b must be rejected. + let game_b = deploy_initialized_minigame_mock(token.contract_address); + crate::metagame::metagame::assert_game_registered(game_b); +} + // ================================================================================================ // PACKING PARITY HELPERS // ================================================================================================ diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index a9c67b35..ad4cd080 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -39,8 +39,9 @@ pub mod CoreTokenLiteComponent { use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; use crate::token::interface::IMINIGAME_TOKEN_ID; use crate::token::structs::{ - GameContextDetails, TokenMetadata, TokenMutableState, extract_tx_hash_bits, pack_token_id, - to_token_metadata, unpack_minted_by, unpack_settings_id, unpack_soulbound, unpack_token_id, + GameContextDetails, MintBatchRecipient, TokenMetadata, TokenMutableState, + extract_tx_hash_bits, pack_token_id, to_token_metadata, unpack_minted_by, + unpack_settings_id, unpack_soulbound, unpack_token_id, }; use crate::token::token::{LifecycleTrait, token_state}; use crate::token::traits::OptionalMinter; @@ -247,6 +248,144 @@ pub mod CoreTokenLiteComponent { final_token_id } + /// Batch mint identical tokens to one or more recipients with per-recipient + /// counts. ABI-compatible with `IMinigameToken::mint_batch_recipients` so + /// batch-minting metagames (tournaments, brackets) work unchanged against a + /// lite deployment; the same unsupported-parameter rules as `mint` apply. + /// + /// Salt is a single global counter across the batch (`salt + i` for + /// `i in 0..sum(counts)`), identical to the full token: token ids do not + /// encode the recipient, so salts must be globally unique within the tx — + /// `salt + sum(counts) - 1 <= 0x3FF` (10-bit field). + /// + /// Versus calling `mint` per token, the lifecycle math, tx-info read, game + /// check and minter registration are hoisted and paid once for the batch. + 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: u16, + ) -> Array { + assert!(objective_id.is_none(), "MinigameTokenLite: objectives not supported"); + assert!(context.is_none(), "MinigameTokenLite: context not supported"); + assert!(client_url.is_none(), "MinigameTokenLite: client_url not supported"); + assert!( + renderer_address.is_none(), "MinigameTokenLite: per-token renderer not supported", + ); + assert!(skills_address.is_none(), "MinigameTokenLite: skills not supported"); + assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); + assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); + assert!( + game_address == self.game_address.read(), + "MinigameTokenLite: Game address does not match configured game", + ); + + let recipient_count = recipients.len(); + assert!(recipient_count > 0, "MinigameTokenLite: 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, "MinigameTokenLite: 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 <= 0x3FF, + "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 1023)", + ); + + // 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), + "MinigameTokenLite: 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 mut contract_self = self.get_contract_mut(); + let minted_by = MinterOpt::add_minter(ref contract_self, caller); + let validated_settings_id = settings_id.unwrap_or(0); + + // Per-token work: pack, optional name write, 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( + 0, // game_id: always 0 — single game + minted_by, + validated_settings_id, + current_time, + start_delay, + end_delay, + 0, // objective_id + soulbound, + false, // has_context + false, // paymaster + tx_hash_bits, + salt + salt_offset, + 0 // metadata + ); + + if let Option::Some(name) = player_name { + self.token_player_names.entry(final_token_id).write(name); + } + + 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, diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo index e2cbf99d..fc837875 100644 --- a/packages/interfaces/src/token/lite.cairo +++ b/packages/interfaces/src/token/lite.cairo @@ -22,7 +22,7 @@ // emit) is the only post-action hook a game needs. use starknet::ContractAddress; use crate::structs::metagame::GameContextDetails; -use crate::structs::token::TokenMetadata; +use crate::structs::token::{MintBatchRecipient, TokenMetadata}; /// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. /// @@ -31,7 +31,7 @@ use crate::structs::token::TokenMetadata; /// against a stripped copy of this trait (see packages/interfaces/src/AGENTS.md) /// to rederive. pub const IMINIGAME_TOKEN_LITE_ID: felt252 = - 0x3ea3d599077fbe09ddbe82ff33c1abc87aef52d8609d8bf3508fdba8dd92056; + 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7; #[starknet::interface] pub trait IMinigameTokenLite { @@ -77,6 +77,28 @@ pub trait IMinigameTokenLite { salt: u16, metadata: u16, ) -> felt252; + /// Batch mint with per-recipient counts. Signature-compatible with + /// `IMinigameToken::mint_batch_recipients`; the same unsupported-parameter + /// rules as `mint` apply, and salt is a single global counter across the + /// batch (`salt + sum(counts) - 1 <= 0x3FF`). + 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; /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see /// `IMinigameToken::refresh_metadata` for the spam/existence trade-offs; /// identical semantics here. diff --git a/packages/test_common/src/examples.cairo b/packages/test_common/src/examples.cairo index 94fab869..249a01a4 100644 --- a/packages/test_common/src/examples.cairo +++ b/packages/test_common/src/examples.cairo @@ -2,3 +2,4 @@ pub mod full_token_contract; pub mod minigame_registry_contract; pub mod minimal_optimized_example; pub mod single_game_token_contract; +pub mod token_lite_contract; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo b/packages/test_common/src/examples/token_lite_contract.cairo similarity index 89% rename from packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo rename to packages/test_common/src/examples/token_lite_contract.cairo index 2bb6ffcc..fd56c7af 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/examples/token_lite_contract.cairo +++ b/packages/test_common/src/examples/token_lite_contract.cairo @@ -2,6 +2,9 @@ // minter tracking, and a soulbound transfer guard. No registry, no enumerable, // no objectives/context/skills/renderer extensions, no mutable token state. // +// Lives in test_common so downstream consumers (e.g. tournament platforms) can +// declare it from their own test suites via `build-external-contracts`. +// // A production deployment would additionally override `token_uri` to call its // game renderer contract (one stored address, one call) and add // Ownable/Upgradeable — omitted here to keep the example focused on the @@ -10,12 +13,12 @@ #[starknet::contract] pub mod TokenLiteContract { use core::num::traits::Zero; + use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; + use game_components_embeddable_game_standard::token::structs::unpack_soulbound; + use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_token::erc721::ERC721Component; use starknet::ContractAddress; - use crate::token::extensions::minter::minter::MinterComponent; - use crate::token::structs::unpack_soulbound; - use crate::token_lite::token_lite_component::CoreTokenLiteComponent; component!(path: ERC721Component, storage: erc721, event: ERC721Event); component!(path: SRC5Component, storage: src5, event: SRC5Event); From c58983bc1f32ddcdc8f8982e3d06f76e0fc0c327 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:16:18 -0700 Subject: [PATCH 05/33] fix(minigame): skip token settings/objectives announcements on lite tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game-side settings/objectives extensions unconditionally dispatched create_settings/create_objective to the token — entrypoints a lite token does not have — bricking settings creation (including constructors that create default settings) for any game wired to a lite token. The token-side call stores nothing; it is an indexer announcement, and the game remains the source of truth. Probe SRC5 for the token extension id and skip the announcement when the surface is absent. Co-Authored-By: Claude Fable 5 --- .../minigame/extensions/objectives/libs.cairo | 16 ++++++- .../minigame/extensions/settings/libs.cairo | 20 ++++++++- .../minigame/tests/test_objectives_libs.cairo | 44 +++++++++++++++++++ .../minigame/tests/test_settings_libs.cairo | 25 +++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) 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..9c4a8e76 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, + 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 lite +/// 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..ad7830c2 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, + IMINIGAME_TOKEN_SETTINGS_ID, IMinigameTokenSettingsDispatcher, + IMinigameTokenSettingsDispatcherTrait, }; use game_components_embeddable_game_standard::token::interface::{ IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::minigame::extensions::settings::structs::GameSettingDetails; @@ -22,7 +24,17 @@ pub fn get_settings_id(minigame_token_address: ContractAddress, token_id: felt25 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). +/// Lite 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 lite 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/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( From af65a989b45edd632a685765e87b3d8a35f187e1 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:03:13 -0700 Subject: [PATCH 06/33] feat(presets): MinigameTokenLite deployable + two-phase game binding The lite token and its game contract need each other's address at construction (the game's MinigameComponent::initializer SRC5-checks the token; the token binds its single game). Split the lite initializer into register_interfaces + bind_game (one-time) so real deployments can break the cycle: deploy the token unbound, deploy the game pointing at it, then bind. An unbound token cannot mint. Adds the production preset (ERC721 + CoreTokenLite + Minter + soulbound guard + Ownable + Upgradeable, Option constructor, owner-gated bind_game) and openzeppelin_upgrades to workspace deps. Co-Authored-By: Claude Fable 5 --- Scarb.toml | 1 + .../src/token_lite/token_lite_component.cairo | 22 ++- packages/presets/Scarb.toml | 3 + packages/presets/src/lib.cairo | 5 +- .../presets/src/minigame_token_lite.cairo | 164 ++++++++++++++++++ packages/presets/src/tests.cairo | 1 + .../src/tests/test_minigame_token_lite.cairo | 109 ++++++++++++ 7 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 packages/presets/src/minigame_token_lite.cairo create mode 100644 packages/presets/src/tests/test_minigame_token_lite.cairo diff --git a/Scarb.toml b/Scarb.toml index cfc8e7b3..b12e0256 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -38,6 +38,7 @@ starknet = "2.16.1" snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = "v0.58.1" } openzeppelin_access = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_introspection = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } +openzeppelin_upgrades = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_token = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_interfaces = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } ekubo = { git = "https://github.com/EkuboProtocol/starknet-contracts.git", tag = "v4.0.1" } diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index ad4cd080..8d296c84 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -434,9 +434,18 @@ pub mod CoreTokenLiteComponent { +ERC721Component::ERC721HooksTrait, > of InternalTrait { fn initializer(ref self: ComponentState, game_address: ContractAddress) { - assert!(!game_address.is_zero(), "MinigameTokenLite: Game address is zero"); - self.game_address.write(game_address); + self.register_interfaces(); + self.bind_game(game_address); + } + /// Registers the SRC5 interface ids without binding a game — the first + /// half of a two-phase initialization for deployments where the game + /// contract needs the token address in ITS constructor (mutual + /// constructor dependency): deploy the token with interfaces only, + /// deploy the game pointing at the token (its SRC5 check passes), then + /// `bind_game`. An unbound token cannot mint: `mint` requires the + /// caller-supplied game address to equal the stored one, which is zero. + fn register_interfaces(ref self: ComponentState) { let mut contract = self.get_contract_mut(); let mut src5_component = SRC5::get_component_mut(ref contract); src5_component.register_interface(IMINIGAME_TOKEN_LITE_ID); @@ -449,6 +458,15 @@ pub mod CoreTokenLiteComponent { src5_component.register_interface(IMINIGAME_TOKEN_ID); } + /// Binds the single game, exactly once. The binding is immutable + /// thereafter — the game address is the token's trust anchor, and + /// every mint and playability check is defined against it. + fn bind_game(ref self: ComponentState, game_address: ContractAddress) { + assert!(!game_address.is_zero(), "MinigameTokenLite: Game address is zero"); + assert!(self.game_address.read().is_zero(), "MinigameTokenLite: Game is already bound"); + self.game_address.write(game_address); + } + /// 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. diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml index 3b235ca1..d03349c5 100644 --- a/packages/presets/Scarb.toml +++ b/packages/presets/Scarb.toml @@ -8,11 +8,14 @@ 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_upgrades.workspace = true +openzeppelin_interfaces.workspace = true ekubo.workspace = true [[target.starknet-contract]] diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index 70afc81c..7dc2c2a3 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -1,5 +1,7 @@ // SPDX-License-Identifier: BUSL-1.1 +pub mod autonomous_buyback; +pub mod leaderboard; /// # Game Components Presets /// /// Ready-to-deploy contracts built with game components. @@ -11,8 +13,7 @@ /// - **AutonomousBuyback**: Autonomous token buyback via Ekubo TWAMM /// - **StreamToken**: ERC20 token with built-in TWAMM distribution -pub mod autonomous_buyback; -pub mod leaderboard; +pub mod minigame_token_lite; pub mod stream_token; pub use autonomous_buyback::AutonomousBuyback; diff --git a/packages/presets/src/minigame_token_lite.cairo b/packages/presets/src/minigame_token_lite.cairo new file mode 100644 index 00000000..befa2742 --- /dev/null +++ b/packages/presets/src/minigame_token_lite.cairo @@ -0,0 +1,164 @@ +// # MinigameTokenLite preset +// +// Production-deployable single-game lite token ("denshokan lite"): ERC721 + +// CoreTokenLiteComponent + minter tracking + soulbound guard + Ownable + +// Upgradeable. No registry, no enumerable, no mutable token state, no +// objectives/context/skills/renderer extensions. +// +// Deployment supports the mutual-constructor-dependency dance with the game +// contract: pass `game_address: Option::None` to deploy unbound (SRC5 ids are +// registered so the game's `MinigameComponent::initializer` accepts this +// token), deploy the game pointing here, then call `bind_game` (owner, once). +// An unbound token cannot mint. +// +// `token_uri` is OpenZeppelin's base_uri concatenation. A game wanting fully +// on-chain art should upgrade to a class that overrides `token_uri` to call +// its renderer contract. + +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IMinigameTokenLiteAdmin { + /// One-time game binding for two-phase deployments. Owner-only. + fn bind_game(ref self: TState, game_address: ContractAddress); +} + +#[starknet::contract] +pub mod MinigameTokenLite { + use core::num::traits::Zero; + use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; + use game_components_embeddable_game_standard::token::structs::unpack_soulbound; + use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; + use openzeppelin_access::ownable::OwnableComponent; + use openzeppelin_interfaces::upgrades::IUpgradeable; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_token::erc721::ERC721Component; + use openzeppelin_upgrades::UpgradeableComponent; + use starknet::{ClassHash, ContractAddress}; + use super::IMinigameTokenLiteAdmin; + + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); + component!(path: MinterComponent, storage: minter, event: MinterEvent); + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + core_token_lite: CoreTokenLiteComponent::Storage, + #[substorage(v0)] + minter: MinterComponent::Storage, + #[substorage(v0)] + ownable: OwnableComponent::Storage, + #[substorage(v0)] + upgradeable: UpgradeableComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + CoreTokenLiteEvent: CoreTokenLiteComponent::Event, + #[flat] + MinterEvent: MinterComponent::Event, + #[flat] + OwnableEvent: OwnableComponent::Event, + #[flat] + UpgradeableEvent: UpgradeableComponent::Event, + } + + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + #[abi(embed_v0)] + impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + #[abi(embed_v0)] + impl CoreTokenLiteImpl = + CoreTokenLiteComponent::CoreTokenLiteImpl; + #[abi(embed_v0)] + impl MinterImpl = MinterComponent::MinterImpl; + #[abi(embed_v0)] + impl OwnableImpl = OwnableComponent::OwnableMixinImpl; + + impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl SRC5InternalImpl = SRC5Component::InternalImpl; + impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; + impl MinterInternalImpl = MinterComponent::InternalImpl; + impl OwnableInternalImpl = OwnableComponent::InternalImpl; + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; + + // Minter is the only optional feature the lite core consumes. + impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; + + 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 and burns 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, + owner: ContractAddress, + name: ByteArray, + symbol: ByteArray, + base_uri: ByteArray, + game_address: Option, + ) { + assert!(!owner.is_zero(), "MinigameTokenLite: owner cannot be zero"); + self.ownable.initializer(owner); + self.erc721.initializer(name, symbol, base_uri); + self.minter.initializer(); + match game_address { + Option::Some(game) => self.core_token_lite.initializer(game), + // Two-phase deployment: interfaces now, bind_game later. + Option::None => self.core_token_lite.register_interfaces(), + } + } + + #[abi(embed_v0)] + impl AdminImpl of IMinigameTokenLiteAdmin { + fn bind_game(ref self: ContractState, game_address: ContractAddress) { + self.ownable.assert_only_owner(); + self.core_token_lite.bind_game(game_address); + } + } + + #[abi(embed_v0)] + impl UpgradeableImpl of IUpgradeable { + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { + self.ownable.assert_only_owner(); + self.upgradeable.upgrade(new_class_hash); + } + } +} diff --git a/packages/presets/src/tests.cairo b/packages/presets/src/tests.cairo index 61152534..f4177e3d 100644 --- a/packages/presets/src/tests.cairo +++ b/packages/presets/src/tests.cairo @@ -1,4 +1,5 @@ mod mocks; mod test_autonomous_buyback; mod test_leaderboard_preset; +mod test_minigame_token_lite; mod test_stream_token; diff --git a/packages/presets/src/tests/test_minigame_token_lite.cairo b/packages/presets/src/tests/test_minigame_token_lite.cairo new file mode 100644 index 00000000..7ce44176 --- /dev/null +++ b/packages/presets/src/tests/test_minigame_token_lite.cairo @@ -0,0 +1,109 @@ +use game_components_embeddable_game_standard::token_lite::interface::{ + IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, +}; +use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; +use snforge_std::{CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare}; +use starknet::ContractAddress; +use crate::minigame_token_lite::{ + IMinigameTokenLiteAdminDispatcher, IMinigameTokenLiteAdminDispatcherTrait, +}; + +fn addr(v: felt252) -> ContractAddress { + v.try_into().unwrap() +} + +fn OWNER() -> ContractAddress { + addr('OWNER') +} + +fn GAME() -> ContractAddress { + addr('GAME') +} + +fn ALICE() -> ContractAddress { + addr('ALICE') +} + +fn deploy(game: Option) -> ContractAddress { + let class = declare("MinigameTokenLite").unwrap().contract_class(); + let mut calldata: Array = array![]; + OWNER().serialize(ref calldata); + let name: ByteArray = "Lite"; + let symbol: ByteArray = "LT"; + let base_uri: ByteArray = "https://lite.test/"; + name.serialize(ref calldata); + symbol.serialize(ref calldata); + base_uri.serialize(ref calldata); + game.serialize(ref calldata); + let (address, _) = class.deploy(@calldata).unwrap(); + address +} + +fn mint_neutral(token: IMinigameTokenLiteDispatcher, game: ContractAddress) -> felt252 { + token + .mint( + game, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + Option::None, + ALICE(), + false, + false, + 0, + 0, + ) +} + +#[test] +fn test_bound_at_construction_mints() { + let address = deploy(Option::Some(GAME())); + let token = IMinigameTokenLiteDispatcher { contract_address: address }; + assert!(token.game_address() == GAME(), "Game should be bound at construction"); + let token_id = mint_neutral(token, GAME()); + let erc721 = ERC721ABIDispatcher { contract_address: address }; + assert!(erc721.owner_of(token_id.into()) == ALICE(), "Mint should work when bound"); +} + +#[test] +fn test_two_phase_bind_then_mint() { + let address = deploy(Option::None); + let token = IMinigameTokenLiteDispatcher { contract_address: address }; + assert!(token.game_address() == addr(0), "Unbound token has zero game"); + + cheat_caller_address(address, OWNER(), CheatSpan::TargetCalls(1)); + IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(GAME()); + assert!(token.game_address() == GAME(), "Game should be bound"); + + let token_id = mint_neutral(token, GAME()); + let erc721 = ERC721ABIDispatcher { contract_address: address }; + assert!(erc721.owner_of(token_id.into()) == ALICE(), "Mint should work after binding"); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Game address does not match configured game")] +fn test_unbound_token_cannot_mint() { + let address = deploy(Option::None); + mint_neutral(IMinigameTokenLiteDispatcher { contract_address: address }, GAME()); +} + +#[test] +#[should_panic(expected: "MinigameTokenLite: Game is already bound")] +fn test_bind_game_only_once() { + let address = deploy(Option::Some(GAME())); + cheat_caller_address(address, OWNER(), CheatSpan::TargetCalls(1)); + IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(addr('OTHER')); +} + +#[test] +#[should_panic(expected: 'Caller is not the owner')] +fn test_bind_game_owner_only() { + let address = deploy(Option::None); + cheat_caller_address(address, ALICE(), CheatSpan::TargetCalls(1)); + IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(GAME()); +} From 3bb9faca83124c586d3b640396c918982b08fd4e Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:20:24 -0700 Subject: [PATCH 07/33] docs: denshokan-lite -> one-address migration write-up Full change log vs the original architecture with rationale and measured results across game-components #123, SDM #149/#150 and budokan #313, including the Sepolia E2E verification and per-game cost impact. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/denshokan-lite-migration.md diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md new file mode 100644 index 00000000..74df3198 --- /dev/null +++ b/docs/denshokan-lite-migration.md @@ -0,0 +1,121 @@ +# 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%. + +## 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). + +--- + +## 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%)** | + +**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. From 612ee9786c9291470e05c9010cf8be265b640c1e Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:25:11 -0700 Subject: [PATCH 08/33] docs: per-action gas estimates for the remaining entrypoints Anchors harness action deltas to each stack's measured on-chain overhead (reproduces the measured attack numbers exactly) to estimate explore, surrender and select_stat_upgrades; notes the mock under-charge bias and the light-action/batching interaction. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index 74df3198..f4bdca19 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -104,6 +104,19 @@ The library-class pattern (already used by budokan's rewards class) dissolved th | 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. From 4adda5c162dcc5f6d8d888fda6d14ed6c5c66ef2 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:18:26 -0700 Subject: [PATCH 09/33] =?UTF-8?q?refactor(token=5Flite)!:=20commit=20to=20?= =?UTF-8?q?self-binding=20=E2=80=94=20the=20game=20contract=20IS=20the=20t?= =?UTF-8?q?oken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite component briefly supported two deployment shapes: embedded in the game contract (one-address) and as a separate token contract paired to a game. Measurements showed the separate shape strictly worse on gas, and supporting it kept dead machinery alive. The component is now self-bound only: - Delete the game_address storage slot; game_address() returns the contract's own address (kept as a view for ecosystem consumers). - mint/mint_batch_recipients keep the game_address parameter for ABI parity; it must equal the contract's own address (same error string). - Collapse InternalTrait to a single no-arg initializer registering the two SRC5 ids; delete bind_game and the two-phase register_interfaces (they only existed to break the removed shape's constructor circularity). - Delete the MinigameTokenLite preset and the minigame::lite pre_action/post_action helpers — in the one-address world the game calls the component internally. - assert_game_registered's registry-less branch becomes a plain token_address == game_address equality, saving a cross-contract call at tournament creation. - Replace the TokenLiteContract example with a merged LiteGameMock (one contract that is both game and token) and rework the token_lite tests and gas bench around it. The IMinigameTokenLite ABI and IMINIGAME_TOKEN_LITE_ID are unchanged. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 2 + packages/embeddable_game_standard/Scarb.toml | 2 +- .../src/metagame/metagame.cairo | 13 +- .../src/minigame.cairo | 1 - .../src/minigame/lite.cairo | 35 -- .../src/token_lite.cairo | 5 +- .../src/token_lite/AGENTS.md | 47 +-- .../src/token_lite/tests.cairo | 4 +- .../src/token_lite/tests/test_gas_bench.cairo | 14 +- .../token_lite/tests/test_token_lite.cairo | 198 +++--------- .../src/token_lite/token_lite_component.cairo | 72 ++--- packages/interfaces/src/AGENTS.md | 2 +- packages/interfaces/src/token/lite.cairo | 18 +- packages/presets/Scarb.toml | 1 - packages/presets/src/lib.cairo | 1 - .../presets/src/minigame_token_lite.cairo | 164 ---------- packages/presets/src/tests.cairo | 1 - .../src/tests/test_minigame_token_lite.cairo | 109 ------- packages/test_common/src/AGENTS.md | 1 + packages/test_common/src/examples.cairo | 1 - .../src/examples/token_lite_contract.cairo | 111 ------- packages/test_common/src/mocks.cairo | 1 + .../src/mocks/lite_game_mock.cairo | 304 ++++++++++++++++++ 23 files changed, 441 insertions(+), 666 deletions(-) delete mode 100644 packages/embeddable_game_standard/src/minigame/lite.cairo delete mode 100644 packages/presets/src/minigame_token_lite.cairo delete mode 100644 packages/presets/src/tests/test_minigame_token_lite.cairo delete mode 100644 packages/test_common/src/examples/token_lite_contract.cairo create mode 100644 packages/test_common/src/mocks/lite_game_mock.cairo diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index f4bdca19..fcefe87e 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -53,6 +53,8 @@ The audits that preceded the changes established the key fact that made everythi **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 | diff --git a/packages/embeddable_game_standard/Scarb.toml b/packages/embeddable_game_standard/Scarb.toml index 6285f759..c59fcda8 100644 --- a/packages/embeddable_game_standard/Scarb.toml +++ b/packages/embeddable_game_standard/Scarb.toml @@ -28,5 +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::examples::token_lite_contract::TokenLiteContract", + "game_components_test_common::mocks::lite_game_mock::LiteGameMock", ] diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 9e309cee..099c9975 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -15,11 +15,12 @@ use crate::metagame::structs::MintMetagameParams; /// Asserts that a game is registered in the minigame token contract /// /// For registry-backed (multi-game) tokens this asks the registry. For tokens -/// with no registry — single-game full tokens and lite tokens both report a -/// zero `game_registry_address()` — "registered" means the pairing is mutual: -/// the game names this token, and the token's one configured game is this -/// game. Previously this path dispatched to the zero address and reverted -/// with CONTRACT_NOT_DEPLOYED for any single-game token. +/// with no registry — a zero `game_registry_address()` — "registered" means +/// the pairing is mutual, and self-bound lite tokens (the game contract IS the +/// token) make that a plain address equality: the game's `token_address()` +/// must be the game itself. This saves a cross-contract `game_address()` read +/// at tournament creation. Previously this path dispatched to the zero +/// address and reverted with CONTRACT_NOT_DEPLOYED for any single-game token. /// /// # Arguments /// * `game_address` - The address of the game contract to check @@ -31,7 +32,7 @@ pub fn assert_game_registered(game_address: ContractAddress) { }; 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"); + assert!(minigame_token_address == game_address, "Game is not registered"); return; } let minigame_registry_dispatcher = IMinigameRegistryDispatcher { diff --git a/packages/embeddable_game_standard/src/minigame.cairo b/packages/embeddable_game_standard/src/minigame.cairo index 119928f7..f4d3c3c8 100644 --- a/packages/embeddable_game_standard/src/minigame.cairo +++ b/packages/embeddable_game_standard/src/minigame.cairo @@ -1,6 +1,5 @@ pub mod extensions; pub mod interface; -pub mod lite; pub mod minigame; pub mod minigame_component; pub mod structs; diff --git a/packages/embeddable_game_standard/src/minigame/lite.cairo b/packages/embeddable_game_standard/src/minigame/lite.cairo deleted file mode 100644 index 777d07f2..00000000 --- a/packages/embeddable_game_standard/src/minigame/lite.cairo +++ /dev/null @@ -1,35 +0,0 @@ -// Lite-token twins of `minigame::minigame::pre_action` / `post_action`. -// -// Same call-site shape as the full-token helpers, different token contract and -// deliberately different semantics — the module path is what signals the shift: -// -// * `pre_action` folds the old `assert_token_ownership` + `pre_action` pair -// into ONE cross-contract call. The lite token checks that this contract's -// caller owns the token and that the lifecycle window is open. There is no -// token-side game_over/objective latch to consult — with the lite token the -// game contract is the sole authority on those, and must gate finished runs -// itself. -// * `post_action` emits an ERC-4906 refresh and nothing else. There is no -// `update_game` on the lite token and no state to sync back. -// -// Like the full-token helpers, these are free functions that run in the game -// contract's own execution context, so `get_caller_address()` inside -// `pre_action` is the game's caller (the player). -use starknet::{ContractAddress, get_caller_address}; -use crate::token_lite::interface::{IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait}; - -/// Asserts the game's caller owns `token_id` and its lifecycle window is open. -/// One external call — replaces the full-token `assert_token_ownership` + -/// `pre_action` pair. -pub fn pre_action(minigame_token_address: ContractAddress, token_id: felt252) { - IMinigameTokenLiteDispatcher { contract_address: minigame_token_address } - .assert_owner_and_playable(token_id, get_caller_address()); -} - -/// Emits a state-free ERC-4906 `MetadataUpdate` for `token_id` so indexers and -/// marketplaces observe the action. Writes nothing; safe to call while the -/// roll's outcome is sealed. -pub fn post_action(minigame_token_address: ContractAddress, token_id: felt252) { - IMinigameTokenLiteDispatcher { contract_address: minigame_token_address } - .refresh_metadata(token_id); -} diff --git a/packages/embeddable_game_standard/src/token_lite.cairo b/packages/embeddable_game_standard/src/token_lite.cairo index 19cf8800..d67d6b6d 100644 --- a/packages/embeddable_game_standard/src/token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite.cairo @@ -1,7 +1,8 @@ pub mod interface; -// The deployable example (TokenLiteContract) lives in the test_common package -// so downstream consumers can declare it via build-external-contracts. +// The deployable merged game+token mock (LiteGameMock) lives in the +// test_common package so downstream consumers can declare it via +// build-external-contracts. #[cfg(test)] mod tests; pub mod token_lite_component; diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index 71a93f35..b1853f84 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -1,15 +1,21 @@ # Token Lite Module — CoreTokenLiteComponent (ERC721) Gas-optimized single-game variant of the `token` module ("denshokan lite"). -Built for deployments that embed exactly one game, never used the multi-game +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. +**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 | | --- | --- | -| One game, configured at init | No registry, no `game_id` resolution, no SRC5 probes on mint | +| Self-bound: the embedding contract is the game | No stored game address, no registry, no `game_id` resolution, no SRC5 probes on mint; `game_address()` returns `get_contract_address()` (kept as a view for ecosystem consumers); `mint`'s `game_address` parameter survives for ABI parity and must equal the contract's own address | | No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads | | Token id layout is canonical | Reuses `token::structs::pack_token_id` (251-bit) bit-for-bit; unused fields (`game_id`, `objective_id`, `has_context`, `paymaster`, `metadata`) are written as zero | | `mint` is ABI-compatible with `IMinigameToken::mint` | Existing call sites and the `minigame::mint` helper work unchanged; unsupported params are rejected loudly, never silently ignored | @@ -19,10 +25,11 @@ game-over / objective-completion authority in the game contract itself. **Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7` -Defined in `packages/interfaces/src/token/lite.cairo`. The initializer also -registers `IMINIGAME_TOKEN_ID` so `MinigameComponent::initializer` (which -hard-asserts it and then queries `game_registry_address()`) accepts a lite -token; `game_registry_address()` always returns zero. +Defined in `packages/interfaces/src/token/lite.cairo`. The no-arg +`initializer()` registers both `IMINIGAME_TOKEN_LITE_ID` and +`IMINIGAME_TOKEN_ID` — the latter so ecosystem consumers that hard-assert the +full-token id (and then query `game_registry_address()`) accept a lite token; +`game_registry_address()` always returns zero. | Method | Cost | Notes | | --- | --- | --- | @@ -38,30 +45,26 @@ token; `game_registry_address()` always returns zero. Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all batch views, objectives/settings/context/renderer/skills/enumerable surfaces. -## Game-side helpers - -`minigame::lite` provides call-site twins of the full-token helpers so game -code keeps its familiar shape — the module path carries the semantic shift: - -- `lite::pre_action(token_address, token_id)` → one `assert_owner_and_playable` - call (replaces the full-token `assert_token_ownership` + `pre_action` pair) -- `lite::post_action(token_address, token_id)` → `refresh_metadata` only - (there is no `update_game` to run) - ## Composition Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl (`MinterComponent::MinterOptionalImpl` — minter ids gate reward claims in consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` -via `unpack_soulbound` — pure, no storage). +via `unpack_soulbound` — pure, no storage). The embedding contract is the game: +it implements `IMinigameTokenData` (score/game_over) itself and calls the +component's guards (`assert_owner_and_playable`) and `refresh_metadata` +internally — the former `minigame::lite::{pre_action, post_action}` +cross-contract helpers were deleted with the separate-token shape. -See `test_common/src/examples/token_lite_contract.cairo` for a full wiring -example — it lives in the test_common package so downstream consumers can -declare `TokenLiteContract` in their own suites via `build-external-contracts`. +See `test_common/src/mocks/lite_game_mock.cairo` (`LiteGameMock`) 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` accepts -registry-less tokens (zero `game_registry_address()`) by asserting the mutual -game ↔ token pairing instead of dispatching to the registry. +registry-less tokens (zero `game_registry_address()`) by asserting +`token_address == game_address` — with self-binding the pairing is a plain +address equality, no cross-contract `game_address()` read. ## Testing diff --git a/packages/embeddable_game_standard/src/token_lite/tests.cairo b/packages/embeddable_game_standard/src/token_lite/tests.cairo index 1d6fb7ef..e5099565 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests.cairo @@ -1,7 +1,7 @@ // Token lite package tests // -// The deployable example contract (TokenLiteContract) is declared from -// game_components_test_common::examples via build-external-contracts. +// The deployable merged game+token contract (LiteGameMock) is declared from +// game_components_test_common::mocks via build-external-contracts. mod test_gas_bench; mod test_token_lite; diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo index 13886822..ee7e7ae1 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo @@ -1,5 +1,7 @@ -// Gas benchmarks: CoreTokenLiteComponent vs the full CoreTokenComponent in its -// deployed-denshokan configuration (multi-game registry + all extensions). +// Gas benchmarks: CoreTokenLiteComponent (self-bound in the one-address +// LiteGameMock — game and token are the same contract) vs the full +// 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. @@ -50,9 +52,10 @@ fn deploy_mock_game() -> ContractAddress { contract_address } +/// One-address shape: the LiteGameMock contract is both the game and the +/// token, so the returned "game" address is the token contract itself. fn setup_lite() -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, ContractAddress) { - let game = deploy_mock_game(); - let contract = declare("TokenLiteContract").unwrap().contract_class(); + let contract = declare("LiteGameMock").unwrap().contract_class(); let mut calldata: Array = array![]; let name: ByteArray = "LiteToken"; let symbol: ByteArray = "LITE"; @@ -60,13 +63,12 @@ fn setup_lite() -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, ContractA name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - game.serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); start_cheat_block_timestamp(contract_address, START_TIME); ( IMinigameTokenLiteDispatcher { contract_address }, ERC721ABIDispatcher { contract_address }, - game, + contract_address, ) } diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index b73e96a4..8a978643 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -1,9 +1,8 @@ -use game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcherTrait; use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use snforge_std::{ CheatSpan, ContractClassTrait, DeclareResultTrait, EventSpyAssertionsTrait, - cheat_caller_address, declare, spy_events, start_cheat_block_timestamp, + cheat_caller_address, declare, mock_call, spy_events, start_cheat_block_timestamp, }; use starknet::ContractAddress; use crate::token::extensions::minter::interface::{ @@ -22,10 +21,6 @@ fn addr(value: felt252) -> ContractAddress { value.try_into().unwrap() } -fn GAME() -> ContractAddress { - addr('GAME') -} - fn ALICE() -> ContractAddress { addr('ALICE') } @@ -38,10 +33,12 @@ fn MINTER() -> ContractAddress { addr('MINTER') } -fn deploy_token_lite_for_game( - game_address: ContractAddress, -) -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher) { - let contract = declare("TokenLiteContract").unwrap().contract_class(); +/// Deploys ONE contract that is both the game and the token — the only +/// supported shape: the lite component is self-binding. +fn deploy_token_lite() -> ( + IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, +) { + let contract = declare("LiteGameMock").unwrap().contract_class(); let mut calldata: Array = array![]; let name: ByteArray = "LiteToken"; let symbol: ByteArray = "LITE"; @@ -49,7 +46,6 @@ fn deploy_token_lite_for_game( name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - game_address.serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); ( IMinigameTokenLiteDispatcher { contract_address }, @@ -58,14 +54,9 @@ fn deploy_token_lite_for_game( ) } -fn deploy_token_lite() -> ( - IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, -) { - deploy_token_lite_for_game(GAME()) -} - /// Mint with lifecycle only — every unsupported parameter at its required -/// neutral value, mirroring how death-mountain-style dungeons call mint. +/// neutral value, mirroring how death-mountain-style dungeons call mint. The +/// "game address" is the token's own address (self-bound). fn mint_basic( token: IMinigameTokenLiteDispatcher, player_name: Option, @@ -78,7 +69,7 @@ fn mint_basic( ) -> felt252 { token .mint( - GAME(), + token.contract_address, player_name, settings_id, start, @@ -104,7 +95,10 @@ fn mint_basic( fn test_deployment_and_interfaces() { let (token, erc721, _) = deploy_token_lite(); - assert!(token.game_address() == GAME(), "Game address should match constructor arg"); + assert!( + token.game_address() == token.contract_address, + "game_address must be the contract itself (self-bound)", + ); assert!(token.game_registry_address() == addr(0), "Registry address should always be zero"); assert!(erc721.name() == "LiteToken", "Name mismatch"); assert!(erc721.symbol() == "LITE", "Symbol mismatch"); @@ -234,7 +228,7 @@ fn test_mint_rejects_objective_id() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -261,7 +255,7 @@ fn test_mint_rejects_context() { }; token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -285,7 +279,7 @@ fn test_mint_rejects_client_url() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -309,7 +303,7 @@ fn test_mint_rejects_renderer() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -333,7 +327,7 @@ fn test_mint_rejects_skills() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -357,7 +351,7 @@ fn test_mint_rejects_paymaster() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -381,7 +375,7 @@ fn test_mint_rejects_metadata() { let (token, _, _) = deploy_token_lite(); token .mint( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -670,7 +664,7 @@ fn batch_neutral( ) -> Array { token .mint_batch_recipients( - GAME(), + token.contract_address, Option::Some('bench'), Option::Some(5), Option::None, @@ -756,7 +750,7 @@ fn test_mint_batch_recipients_rejects_context() { }; token .mint_batch_recipients( - GAME(), + token.contract_address, Option::None, Option::None, Option::None, @@ -775,143 +769,33 @@ fn test_mint_batch_recipients_rejects_context() { } // ================================================================================================ -// MINIGAME LITE HELPERS (minigame::lite::pre_action / post_action) +// ECOSYSTEM INTEGRATION (metagame assert_game_registered) // ================================================================================================ -// -// The helpers are free functions that run in the calling contract's execution -// context: `get_caller_address()` inside `pre_action` is whoever called the -// game contract. The tests model that by cheating the caller of the test -// contract itself (`snforge_std::test_address()`), which plays the game's role. - -#[test] -fn test_lite_pre_action_passes_for_owner_caller() { - let (token, _, _) = deploy_token_lite(); - let token_id = mint_basic( - token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, - ); - snforge_std::start_cheat_caller_address(snforge_std::test_address(), ALICE()); - crate::minigame::lite::pre_action(token.contract_address, token_id); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] -fn test_lite_pre_action_rejects_non_owner_caller() { - let (token, _, _) = deploy_token_lite(); - let token_id = mint_basic( - token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, - ); - snforge_std::start_cheat_caller_address(snforge_std::test_address(), BOB()); - crate::minigame::lite::pre_action(token.contract_address, token_id); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has expired")] -fn test_lite_pre_action_rejects_expired_token() { - let (token, _, _) = deploy_token_lite(); - 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); - snforge_std::start_cheat_caller_address(snforge_std::test_address(), ALICE()); - crate::minigame::lite::pre_action(token.contract_address, token_id); -} +/// Positive path: a self-bound lite deployment IS its own game. Its +/// `token_address()` returns itself and `game_registry_address()` is zero, so +/// the registry-less branch reduces to a trivially-true address equality. #[test] -fn test_lite_post_action_emits_refresh() { +fn test_assert_game_registered_accepts_self_bound_lite_game() { let (token, _, _) = deploy_token_lite(); - let token_id = mint_basic( - token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, - ); - - let mut spy = spy_events(); - crate::minigame::lite::post_action(token.contract_address, token_id); - spy - .assert_emitted( - @array![ - ( - token.contract_address, - CoreTokenLiteComponent::Event::MetadataUpdate( - CoreTokenLiteComponent::MetadataUpdate { token_id: token_id.into() }, - ), - ), - ], - ); -} - -// ================================================================================================ -// GAME-SIDE INTEGRATION (MinigameComponent + metagame assert_game_registered) -// ================================================================================================ - -fn deploy_initialized_minigame_mock(token_address: ContractAddress) -> ContractAddress { - let contract = declare("minigame_mock").unwrap().contract_class(); - let (game_address, _) = contract.deploy(@array![]).unwrap(); - game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcher { - contract_address: game_address, - } - .initializer( - ALICE(), - "Game", - "d", - "dev", - "pub", - "genre", - "img", - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - token_address, - Option::None, - ); - game_address -} - -/// End-to-end: MinigameComponent::initializer SRC5-checks the token for -/// IMINIGAME_TOKEN_ID and queries game_registry_address(); the lite token's -/// legacy-id registration and zero-registry shim must satisfy both, and the -/// metagame lib must then treat the mutual game <-> token pairing as -/// registered. -#[test] -fn test_minigame_initializer_and_game_registered_with_lite_token() { - let game_class = declare("minigame_mock").unwrap().contract_class(); - let (game_address, _) = game_class.deploy(@array![]).unwrap(); - let (token, _, _) = deploy_token_lite_for_game(game_address); - game_components_test_common::mocks::minigame_mock::IMinigameMockInitDispatcher { - contract_address: game_address, - } - .initializer( - ALICE(), - "Game", - "d", - "dev", - "pub", - "genre", - "img", - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - token.contract_address, - Option::None, - ); - - crate::metagame::metagame::assert_game_registered(game_address); + crate::metagame::metagame::assert_game_registered(token.contract_address); } +/// Negative path: a game whose `token_address()` points at some OTHER +/// registry-less token is not a valid pairing — self-binding means the only +/// accepted answer is the game's own address. A second LiteGameMock cannot +/// express this misconfiguration (it always returns itself), so the fake game +/// is a mocked address pointing at a real lite deployment. #[test] #[should_panic(expected: "Game is not registered")] fn test_assert_game_registered_rejects_game_not_paired_with_lite_token() { - let game_class = declare("minigame_mock").unwrap().contract_class(); - let (game_a, _) = game_class.deploy(@array![]).unwrap(); - let (token, _, _) = deploy_token_lite_for_game(game_a); - - // A second game pointing at the same lite token: the token's one - // configured game is game_a, so game_b must be rejected. - let game_b = deploy_initialized_minigame_mock(token.contract_address); - crate::metagame::metagame::assert_game_registered(game_b); + let (token, _, _) = deploy_token_lite(); + + let fake_game = addr('FAKE_GAME'); + mock_call(fake_game, selector!("token_address"), token.contract_address, 1); + // token.game_registry_address() answers zero for real; the address + // equality fake_game == token then fails. + crate::metagame::metagame::assert_game_registered(fake_game); } // ================================================================================================ diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index 8d296c84..17c08d5d 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -6,17 +6,25 @@ /// client urls, and that keep game-over / objective completion authority in /// the game contract itself. /// +/// **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) and the +/// `game_address()` view — which returns the contract's own address — is the +/// honest advertisement of that to ecosystem consumers. +/// /// What is deliberately gone, and why it is safe to remove: -/// * **Registry** — one game, stored once in `game_address`. No -/// `game_id_from_address` on mint, no `game_address_from_id` anywhere. +/// * **Registry** — one game: this contract. No `game_id_from_address` on +/// mint, no `game_address_from_id` anywhere, no stored game address at all. /// * **Mutable token state** — no `game_over`/`completed_objective` latch. /// The game contract is the sole authority; playability here is the /// lifecycle window only, which lives packed inside the token id, so /// `is_playable` costs zero storage reads. /// * **`update_game` + metagame callbacks** — nothing to sync and nobody to /// notify. `refresh_metadata` (ERC-4906) is the only post-action hook. -/// * **SRC5 round-trips** — the game address is trusted at initialization; -/// mint performs no `supports_interface` calls. +/// * **SRC5 round-trips** — the game is this contract; mint performs no +/// `supports_interface` calls. /// * **Settings/objective validation on mint** — minters pass an /// admin-configured `settings_id`; the game validates it at play time. /// @@ -36,7 +44,9 @@ pub mod CoreTokenLiteComponent { use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; + use starknet::{ + ContractAddress, get_block_timestamp, get_caller_address, get_contract_address, get_tx_info, + }; use crate::token::interface::IMINIGAME_TOKEN_ID; use crate::token::structs::{ GameContextDetails, MintBatchRecipient, TokenMetadata, TokenMutableState, @@ -48,7 +58,6 @@ pub mod CoreTokenLiteComponent { #[storage] pub struct Storage { - game_address: ContractAddress, token_player_names: Map, } @@ -142,7 +151,9 @@ pub mod CoreTokenLiteComponent { } fn game_address(self: @ComponentState) -> ContractAddress { - self.game_address.read() + // The token IS the game contract (one-address architecture). Kept + // as a view so ecosystem consumers can still resolve the pairing. + get_contract_address() } fn game_registry_address(self: @ComponentState) -> ContractAddress { @@ -182,10 +193,11 @@ pub mod CoreTokenLiteComponent { assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); - // Single game — no SRC5 probe, no registry resolution. The - // parameter is kept (and checked) purely for call-site parity. + // Single game — this contract. No SRC5 probe, no registry + // resolution. The parameter is kept for ABI parity and still + // catches caller misconfiguration (pointing at the wrong game). assert!( - game_address == self.game_address.read(), + game_address == get_contract_address(), "MinigameTokenLite: Game address does not match configured game", ); @@ -288,7 +300,7 @@ pub mod CoreTokenLiteComponent { assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); assert!( - game_address == self.game_address.read(), + game_address == get_contract_address(), "MinigameTokenLite: Game address does not match configured game", ); @@ -433,40 +445,22 @@ pub mod CoreTokenLiteComponent { +Drop, +ERC721Component::ERC721HooksTrait, > of InternalTrait { - fn initializer(ref self: ComponentState, game_address: ContractAddress) { - self.register_interfaces(); - self.bind_game(game_address); - } - - /// Registers the SRC5 interface ids without binding a game — the first - /// half of a two-phase initialization for deployments where the game - /// contract needs the token address in ITS constructor (mutual - /// constructor dependency): deploy the token with interfaces only, - /// deploy the game pointing at the token (its SRC5 check passes), then - /// `bind_game`. An unbound token cannot mint: `mint` requires the - /// caller-supplied game address to equal the stored one, which is zero. - fn register_interfaces(ref self: ComponentState) { + /// Registers the SRC5 interface ids. There is no game argument — the + /// component is self-bound: the embedding contract is the game. + fn initializer(ref self: ComponentState) { let mut contract = self.get_contract_mut(); let mut src5_component = SRC5::get_component_mut(ref contract); src5_component.register_interface(IMINIGAME_TOKEN_LITE_ID); - // Also advertise the full-token id: MinigameComponent::initializer - // hard-asserts it before wiring a game to its token. The lite - // token implements the subset of IMinigameToken that game-side - // components actually call (mint, assert_is_playable, player_name, - // refresh_metadata, game_registry_address); anything else reverts - // with ENTRYPOINT_NOT_FOUND rather than misbehaving silently. + // Also advertise the full-token id: ecosystem consumers (e.g. + // metagames) hard-assert it before wiring against a token. The + // lite token implements the subset of IMinigameToken that + // game-side components actually call (mint, assert_is_playable, + // player_name, refresh_metadata, game_registry_address); anything + // else reverts with ENTRYPOINT_NOT_FOUND rather than misbehaving + // silently. src5_component.register_interface(IMINIGAME_TOKEN_ID); } - /// Binds the single game, exactly once. The binding is immutable - /// thereafter — the game address is the token's trust anchor, and - /// every mint and playability check is defined against it. - fn bind_game(ref self: ComponentState, game_address: ContractAddress) { - assert!(!game_address.is_zero(), "MinigameTokenLite: Game address is zero"); - assert!(self.game_address.read().is_zero(), "MinigameTokenLite: Game is already bound"); - self.game_address.write(game_address); - } - /// 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. diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index 6b2a00ae..ae41891d 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -9,7 +9,7 @@ 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/lite` | `IMinigameTokenLite` | Single-game gas-optimized token (no registry, no mutable state) | +| `token/lite` | `IMinigameTokenLite` | Gas-optimized token embedded in the game contract itself (self-bound, no registry, no mutable state) | | `registry` | `IMinigameRegistry` | Game registration and metadata lookup | | `leaderboard` | `ILeaderboard`, `ILeaderboardAdmin`, `IGameDetails` | Tournament scoring and rankings | | `tokenomics/buyback` | `IBuyback`, `IBuybackAdmin` | Autonomous buyback via Ekubo TWAMM | diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo index fc837875..b28d2d6f 100644 --- a/packages/interfaces/src/token/lite.cairo +++ b/packages/interfaces/src/token/lite.cairo @@ -1,10 +1,11 @@ // Lite token interface — single-game, no mutable token state. // -// Gas-optimized subset of `IMinigameToken` for deployments that embed exactly one -// game and let the game contract remain the sole authority on game-over / -// objective completion. The token stores no per-token mutable state: everything -// except `player_name` is unpacked from the token id itself, so every view is -// pure felt arithmetic plus at most one storage read. +// Gas-optimized subset of `IMinigameToken` 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: everything except `player_name` is unpacked from the token id +// itself, so every view is pure felt arithmetic plus at most one storage read. // // `mint` keeps the exact `IMinigameToken::mint` signature (same selector, same // calldata layout) so existing call sites and the `minigame::mint` helper work @@ -48,6 +49,9 @@ pub trait IMinigameTokenLite { 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; + /// Returns this contract's own address — the token IS the game contract + /// (self-binding is the only supported shape). Kept as a view so ecosystem + /// consumers can keep resolving the game ↔ token pairing generically. fn game_address(self: @TState) -> ContractAddress; /// Always returns the zero address — the lite token has no registry. Kept /// so `MinigameComponent::initializer`, which unconditionally queries the @@ -56,7 +60,9 @@ pub trait IMinigameTokenLite { fn game_registry_address(self: @TState) -> ContractAddress; /// Signature-compatible with `IMinigameToken::mint`. `game_address` must be - /// the single configured game; `objective_id`, `context`, `client_url`, + /// the token contract's own address (the game IS the token) — the parameter + /// survives for ABI parity and to catch caller misconfiguration; + /// `objective_id`, `context`, `client_url`, /// `renderer_address`, `skills_address` must be `None`, `paymaster` must be /// `false`, and `metadata` must be `0`. fn mint( diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml index d03349c5..fd7d2d8c 100644 --- a/packages/presets/Scarb.toml +++ b/packages/presets/Scarb.toml @@ -14,7 +14,6 @@ starknet.workspace = true openzeppelin_token.workspace = true openzeppelin_introspection.workspace = true openzeppelin_access.workspace = true -openzeppelin_upgrades.workspace = true openzeppelin_interfaces.workspace = true ekubo.workspace = true diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index 7dc2c2a3..d7907891 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -13,7 +13,6 @@ pub mod leaderboard; /// - **AutonomousBuyback**: Autonomous token buyback via Ekubo TWAMM /// - **StreamToken**: ERC20 token with built-in TWAMM distribution -pub mod minigame_token_lite; pub mod stream_token; pub use autonomous_buyback::AutonomousBuyback; diff --git a/packages/presets/src/minigame_token_lite.cairo b/packages/presets/src/minigame_token_lite.cairo deleted file mode 100644 index befa2742..00000000 --- a/packages/presets/src/minigame_token_lite.cairo +++ /dev/null @@ -1,164 +0,0 @@ -// # MinigameTokenLite preset -// -// Production-deployable single-game lite token ("denshokan lite"): ERC721 + -// CoreTokenLiteComponent + minter tracking + soulbound guard + Ownable + -// Upgradeable. No registry, no enumerable, no mutable token state, no -// objectives/context/skills/renderer extensions. -// -// Deployment supports the mutual-constructor-dependency dance with the game -// contract: pass `game_address: Option::None` to deploy unbound (SRC5 ids are -// registered so the game's `MinigameComponent::initializer` accepts this -// token), deploy the game pointing here, then call `bind_game` (owner, once). -// An unbound token cannot mint. -// -// `token_uri` is OpenZeppelin's base_uri concatenation. A game wanting fully -// on-chain art should upgrade to a class that overrides `token_uri` to call -// its renderer contract. - -use starknet::ContractAddress; - -#[starknet::interface] -pub trait IMinigameTokenLiteAdmin { - /// One-time game binding for two-phase deployments. Owner-only. - fn bind_game(ref self: TState, game_address: ContractAddress); -} - -#[starknet::contract] -pub mod MinigameTokenLite { - use core::num::traits::Zero; - use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; - use game_components_embeddable_game_standard::token::structs::unpack_soulbound; - use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; - use openzeppelin_access::ownable::OwnableComponent; - use openzeppelin_interfaces::upgrades::IUpgradeable; - use openzeppelin_introspection::src5::SRC5Component; - use openzeppelin_token::erc721::ERC721Component; - use openzeppelin_upgrades::UpgradeableComponent; - use starknet::{ClassHash, ContractAddress}; - use super::IMinigameTokenLiteAdmin; - - component!(path: ERC721Component, storage: erc721, event: ERC721Event); - component!(path: SRC5Component, storage: src5, event: SRC5Event); - component!(path: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); - component!(path: MinterComponent, storage: minter, event: MinterEvent); - component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); - component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); - - #[storage] - struct Storage { - #[substorage(v0)] - erc721: ERC721Component::Storage, - #[substorage(v0)] - src5: SRC5Component::Storage, - #[substorage(v0)] - core_token_lite: CoreTokenLiteComponent::Storage, - #[substorage(v0)] - minter: MinterComponent::Storage, - #[substorage(v0)] - ownable: OwnableComponent::Storage, - #[substorage(v0)] - upgradeable: UpgradeableComponent::Storage, - } - - #[event] - #[derive(Drop, starknet::Event)] - enum Event { - #[flat] - ERC721Event: ERC721Component::Event, - #[flat] - SRC5Event: SRC5Component::Event, - #[flat] - CoreTokenLiteEvent: CoreTokenLiteComponent::Event, - #[flat] - MinterEvent: MinterComponent::Event, - #[flat] - OwnableEvent: OwnableComponent::Event, - #[flat] - UpgradeableEvent: UpgradeableComponent::Event, - } - - #[abi(embed_v0)] - impl ERC721Impl = ERC721Component::ERC721Impl; - #[abi(embed_v0)] - impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; - #[abi(embed_v0)] - impl SRC5Impl = SRC5Component::SRC5Impl; - #[abi(embed_v0)] - impl CoreTokenLiteImpl = - CoreTokenLiteComponent::CoreTokenLiteImpl; - #[abi(embed_v0)] - impl MinterImpl = MinterComponent::MinterImpl; - #[abi(embed_v0)] - impl OwnableImpl = OwnableComponent::OwnableMixinImpl; - - impl ERC721InternalImpl = ERC721Component::InternalImpl; - impl SRC5InternalImpl = SRC5Component::InternalImpl; - impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; - impl MinterInternalImpl = MinterComponent::InternalImpl; - impl OwnableInternalImpl = OwnableComponent::InternalImpl; - impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; - - // Minter is the only optional feature the lite core consumes. - impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; - - 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 and burns 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, - owner: ContractAddress, - name: ByteArray, - symbol: ByteArray, - base_uri: ByteArray, - game_address: Option, - ) { - assert!(!owner.is_zero(), "MinigameTokenLite: owner cannot be zero"); - self.ownable.initializer(owner); - self.erc721.initializer(name, symbol, base_uri); - self.minter.initializer(); - match game_address { - Option::Some(game) => self.core_token_lite.initializer(game), - // Two-phase deployment: interfaces now, bind_game later. - Option::None => self.core_token_lite.register_interfaces(), - } - } - - #[abi(embed_v0)] - impl AdminImpl of IMinigameTokenLiteAdmin { - fn bind_game(ref self: ContractState, game_address: ContractAddress) { - self.ownable.assert_only_owner(); - self.core_token_lite.bind_game(game_address); - } - } - - #[abi(embed_v0)] - impl UpgradeableImpl of IUpgradeable { - fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { - self.ownable.assert_only_owner(); - self.upgradeable.upgrade(new_class_hash); - } - } -} diff --git a/packages/presets/src/tests.cairo b/packages/presets/src/tests.cairo index f4177e3d..61152534 100644 --- a/packages/presets/src/tests.cairo +++ b/packages/presets/src/tests.cairo @@ -1,5 +1,4 @@ mod mocks; mod test_autonomous_buyback; mod test_leaderboard_preset; -mod test_minigame_token_lite; mod test_stream_token; diff --git a/packages/presets/src/tests/test_minigame_token_lite.cairo b/packages/presets/src/tests/test_minigame_token_lite.cairo deleted file mode 100644 index 7ce44176..00000000 --- a/packages/presets/src/tests/test_minigame_token_lite.cairo +++ /dev/null @@ -1,109 +0,0 @@ -use game_components_embeddable_game_standard::token_lite::interface::{ - IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, -}; -use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; -use snforge_std::{CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare}; -use starknet::ContractAddress; -use crate::minigame_token_lite::{ - IMinigameTokenLiteAdminDispatcher, IMinigameTokenLiteAdminDispatcherTrait, -}; - -fn addr(v: felt252) -> ContractAddress { - v.try_into().unwrap() -} - -fn OWNER() -> ContractAddress { - addr('OWNER') -} - -fn GAME() -> ContractAddress { - addr('GAME') -} - -fn ALICE() -> ContractAddress { - addr('ALICE') -} - -fn deploy(game: Option) -> ContractAddress { - let class = declare("MinigameTokenLite").unwrap().contract_class(); - let mut calldata: Array = array![]; - OWNER().serialize(ref calldata); - let name: ByteArray = "Lite"; - let symbol: ByteArray = "LT"; - let base_uri: ByteArray = "https://lite.test/"; - name.serialize(ref calldata); - symbol.serialize(ref calldata); - base_uri.serialize(ref calldata); - game.serialize(ref calldata); - let (address, _) = class.deploy(@calldata).unwrap(); - address -} - -fn mint_neutral(token: IMinigameTokenLiteDispatcher, game: ContractAddress) -> felt252 { - token - .mint( - game, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 0, - ) -} - -#[test] -fn test_bound_at_construction_mints() { - let address = deploy(Option::Some(GAME())); - let token = IMinigameTokenLiteDispatcher { contract_address: address }; - assert!(token.game_address() == GAME(), "Game should be bound at construction"); - let token_id = mint_neutral(token, GAME()); - let erc721 = ERC721ABIDispatcher { contract_address: address }; - assert!(erc721.owner_of(token_id.into()) == ALICE(), "Mint should work when bound"); -} - -#[test] -fn test_two_phase_bind_then_mint() { - let address = deploy(Option::None); - let token = IMinigameTokenLiteDispatcher { contract_address: address }; - assert!(token.game_address() == addr(0), "Unbound token has zero game"); - - cheat_caller_address(address, OWNER(), CheatSpan::TargetCalls(1)); - IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(GAME()); - assert!(token.game_address() == GAME(), "Game should be bound"); - - let token_id = mint_neutral(token, GAME()); - let erc721 = ERC721ABIDispatcher { contract_address: address }; - assert!(erc721.owner_of(token_id.into()) == ALICE(), "Mint should work after binding"); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: Game address does not match configured game")] -fn test_unbound_token_cannot_mint() { - let address = deploy(Option::None); - mint_neutral(IMinigameTokenLiteDispatcher { contract_address: address }, GAME()); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: Game is already bound")] -fn test_bind_game_only_once() { - let address = deploy(Option::Some(GAME())); - cheat_caller_address(address, OWNER(), CheatSpan::TargetCalls(1)); - IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(addr('OTHER')); -} - -#[test] -#[should_panic(expected: 'Caller is not the owner')] -fn test_bind_game_owner_only() { - let address = deploy(Option::None); - cheat_caller_address(address, ALICE(), CheatSpan::TargetCalls(1)); - IMinigameTokenLiteAdminDispatcher { contract_address: address }.bind_game(GAME()); -} diff --git a/packages/test_common/src/AGENTS.md b/packages/test_common/src/AGENTS.md index 6a86024a..98eb1d4e 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 | |------|---------| +| `lite_game_mock.cairo` | Merged one-address game+token: embeds `CoreTokenLiteComponent` (self-bound) 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.cairo b/packages/test_common/src/examples.cairo index 249a01a4..94fab869 100644 --- a/packages/test_common/src/examples.cairo +++ b/packages/test_common/src/examples.cairo @@ -2,4 +2,3 @@ pub mod full_token_contract; pub mod minigame_registry_contract; pub mod minimal_optimized_example; pub mod single_game_token_contract; -pub mod token_lite_contract; diff --git a/packages/test_common/src/examples/token_lite_contract.cairo b/packages/test_common/src/examples/token_lite_contract.cairo deleted file mode 100644 index fd56c7af..00000000 --- a/packages/test_common/src/examples/token_lite_contract.cairo +++ /dev/null @@ -1,111 +0,0 @@ -// Example "denshokan lite" deployment: single-game ERC721 with the lite core, -// minter tracking, and a soulbound transfer guard. No registry, no enumerable, -// no objectives/context/skills/renderer extensions, no mutable token state. -// -// Lives in test_common so downstream consumers (e.g. tournament platforms) can -// declare it from their own test suites via `build-external-contracts`. -// -// A production deployment would additionally override `token_uri` to call its -// game renderer contract (one stored address, one call) and add -// Ownable/Upgradeable — omitted here to keep the example focused on the -// component wiring. - -#[starknet::contract] -pub mod TokenLiteContract { - use core::num::traits::Zero; - use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; - use game_components_embeddable_game_standard::token::structs::unpack_soulbound; - use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; - use openzeppelin_introspection::src5::SRC5Component; - use openzeppelin_token::erc721::ERC721Component; - use starknet::ContractAddress; - - component!(path: ERC721Component, storage: erc721, event: ERC721Event); - component!(path: SRC5Component, storage: src5, event: SRC5Event); - component!(path: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); - component!(path: MinterComponent, storage: minter, event: MinterEvent); - - #[storage] - struct Storage { - #[substorage(v0)] - erc721: ERC721Component::Storage, - #[substorage(v0)] - src5: SRC5Component::Storage, - #[substorage(v0)] - core_token_lite: CoreTokenLiteComponent::Storage, - #[substorage(v0)] - minter: MinterComponent::Storage, - } - - #[event] - #[derive(Drop, starknet::Event)] - enum Event { - #[flat] - ERC721Event: ERC721Component::Event, - #[flat] - SRC5Event: SRC5Component::Event, - #[flat] - CoreTokenLiteEvent: CoreTokenLiteComponent::Event, - #[flat] - MinterEvent: MinterComponent::Event, - } - - #[abi(embed_v0)] - impl ERC721Impl = ERC721Component::ERC721Impl; - #[abi(embed_v0)] - impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; - #[abi(embed_v0)] - impl SRC5Impl = SRC5Component::SRC5Impl; - #[abi(embed_v0)] - impl CoreTokenLiteImpl = - CoreTokenLiteComponent::CoreTokenLiteImpl; - #[abi(embed_v0)] - impl MinterImpl = MinterComponent::MinterImpl; - - impl ERC721InternalImpl = ERC721Component::InternalImpl; - impl SRC5InternalImpl = SRC5Component::InternalImpl; - impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; - impl MinterInternalImpl = MinterComponent::InternalImpl; - - // Minter is the only optional feature the lite core consumes. - impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; - - 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_address: ContractAddress, - ) { - self.erc721.initializer(name, symbol, base_uri); - self.core_token_lite.initializer(game_address); - self.minter.initializer(); - } -} diff --git a/packages/test_common/src/mocks.cairo b/packages/test_common/src/mocks.cairo index a2d5d96e..6f8fed53 100644 --- a/packages/test_common/src/mocks.cairo +++ b/packages/test_common/src/mocks.cairo @@ -1,3 +1,4 @@ +pub mod lite_game_mock; pub mod metagame_mock; pub mod minigame_mock; pub mod mock_entry_validator; diff --git a/packages/test_common/src/mocks/lite_game_mock.cairo b/packages/test_common/src/mocks/lite_game_mock.cairo new file mode 100644 index 00000000..b1200f68 --- /dev/null +++ b/packages/test_common/src/mocks/lite_game_mock.cairo @@ -0,0 +1,304 @@ +// Merged one-address mock: ONE contract that is both the game and the lite +// token. This is the only supported shape for `CoreTokenLiteComponent` — the +// component is self-binding, so the game contract IS the token. +// +// The contract wires: +// * ERC721 + SRC5 + CoreTokenLiteComponent + MinterComponent, with the +// soulbound transfer guard in `before_update` (pure `unpack_soulbound`). +// * `IMinigame` views that all return the contract's own address, plus +// `mint_game`/`mint_game_batch` delegating to the embedded lite 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 +// lite announcement path. + +#[starknet::interface] +pub trait ILiteGameMock { + 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, + ); +} + +#[starknet::contract] +pub mod LiteGameMock { + 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::minigame as minigame_libs; + use game_components_embeddable_game_standard::minigame::structs::MintGameParams; + use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; + use game_components_embeddable_game_standard::token::structs::unpack_soulbound; + use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; + 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: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); + component!(path: MinterComponent, storage: minter, event: MinterEvent); + component!(path: SettingsComponent, storage: settings, event: SettingsEvent); + + #[storage] + struct Storage { + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + core_token_lite: CoreTokenLiteComponent::Storage, + #[substorage(v0)] + minter: MinterComponent::Storage, + #[substorage(v0)] + settings: SettingsComponent::Storage, + // Game state — the game contract is the sole authority on score and + // game-over; the lite 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] + CoreTokenLiteEvent: CoreTokenLiteComponent::Event, + #[flat] + MinterEvent: MinterComponent::Event, + #[flat] + SettingsEvent: SettingsComponent::Event, + } + + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + #[abi(embed_v0)] + impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + #[abi(embed_v0)] + impl CoreTokenLiteImpl = + CoreTokenLiteComponent::CoreTokenLiteImpl; + #[abi(embed_v0)] + impl MinterImpl = MinterComponent::MinterImpl; + + impl ERC721InternalImpl = ERC721Component::InternalImpl; + impl SRC5InternalImpl = SRC5Component::InternalImpl; + impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; + impl MinterInternalImpl = MinterComponent::InternalImpl; + impl SettingsInternalImpl = SettingsComponent::InternalImpl; + + // Minter is the only optional feature the lite core consumes. + impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; + + 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, + ) { + self.erc721.initializer(name, symbol, base_uri); + // Self-binding: no game argument — this contract IS the game. + self.core_token_lite.initializer(); + self.minter.initializer(); + // 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() + } + + 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 { + minigame_libs::mint( + get_contract_address(), + get_contract_address(), + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + to, + soulbound, + paymaster, + salt, + metadata, + ) + } + + fn mint_game_batch(self: @ContractState, mints: Array) -> Array { + minigame_libs::mint_batch(get_contract_address(), get_contract_address(), mints) + } + } + + #[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 LiteGameMockImpl of super::ILiteGameMock { + 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); + } + + 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 lite token and silently skips, mirroring minigame_mock's + // flow against a lite deployment. + self + .settings + .create_settings( + get_contract_address(), + new_settings_id, + GameSettingDetails { name, description, settings: settings.span() }, + get_contract_address(), + ); + } + } +} From 55229937a0ba81b0cc915d9aa4edab2102660cd9 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:46:00 -0700 Subject: [PATCH 10/33] =?UTF-8?q?feat(token=5Flite)!:=20lite-native=20toke?= =?UTF-8?q?n-id=20layout=20=E2=80=94=20spare=20bits=20consolidated=20as=20?= =?UTF-8?q?one=20reserved=20high-half=20region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lite token now owns its 251-bit token-id layout (token_lite/packing.cairo) instead of reusing the full token's pack_token_id. The full layout in token/structs.cairo is untouched — it keeps serving legacy denshokan. Indexers must branch their token-id decode by contract generation. Low u128 (128 bits): bits [0-34] minted_at 35 (unix seconds) bits [35-59] start_delay 25 bits [60-84] end_delay 25 (0 = no expiration) bits [85-100] settings_id 16 (ABI stays Option; value <= 0xFFFF) bits [101-126] minted_by 26 (minter id must fit 26 bits) bit [127] soulbound 1 High u128 (123 bits): bits [0-9] tx_hash 10 (last 10 bits of tx hash) bits [10-25] salt 16 (batch bound: salt + sum(counts) - 1 <= 0xFFFF) bits [26-122] reserved 97 (component-owned, ALWAYS packed as zero) Reserved-region rule: the 97 spare bits are one component-owned region with no pack parameter and no public unpack accessor. Future protocol- or game-facing fields are carved from it later; every id minted under this layout provably decodes the region as 0, so carve-outs are non-breaking by construction. The IMinigameTokenLite ABI is unchanged, so IMINIGAME_TOKEN_LITE_ID stays 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7. Behavioral changes: settings_id > 0xFFFF is now rejected at mint; the batch salt bound widens from 0x3FF to 0xFFFF. LiteGameMock's soulbound hook and the token_lite tests migrate to the new packing helpers. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 19 ++ .../src/token_lite.cairo | 1 + .../src/token_lite/AGENTS.md | 40 ++- .../src/token_lite/packing.cairo | 272 ++++++++++++++++++ .../token_lite/tests/test_token_lite.cairo | 123 ++++++-- .../src/token_lite/token_lite_component.cairo | 73 ++--- packages/interfaces/src/token/lite.cairo | 15 +- .../src/mocks/lite_game_mock.cairo | 5 +- 8 files changed, 480 insertions(+), 68 deletions(-) create mode 100644 packages/embeddable_game_standard/src/token_lite/packing.cairo diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index fcefe87e..89c1f083 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -94,6 +94,25 @@ The library-class pattern (already used by budokan's rewards class) dissolved th **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 dead fields (`game_id`, `objective_id`, `has_context`, `paymaster`, `metadata`) are gone from the id; `settings_id` and `salt` widen to 16 bits each; every remaining spare bit is consolidated into one component-owned reserved region. + +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) | reserved(97, always zero)` + +| 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–122 | reserved — component-owned, ALWAYS zero | +| 85–100 | settings_id (≤ 0xFFFF, ABI stays `Option`) | | | +| 101–126 | minted_by (26-bit minter id) | | | +| 127 | soulbound | | | + +The reserved region has no pack parameter and no public unpack accessor; future protocol- or game-facing fields are carved from it later, and because every lite id provably decodes it as 0, such carve-outs are non-breaking by construction. The `IMinigameTokenLite` ABI and `IMINIGAME_TOKEN_LITE_ID` are unchanged (the batch salt bound rises to `salt + sum(counts) - 1 <= 0xFFFF`, and `settings_id > 0xFFFF` is now 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. + --- ## Final measured results diff --git a/packages/embeddable_game_standard/src/token_lite.cairo b/packages/embeddable_game_standard/src/token_lite.cairo index d67d6b6d..99e6529c 100644 --- a/packages/embeddable_game_standard/src/token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite.cairo @@ -1,4 +1,5 @@ pub mod interface; +pub mod packing; // The deployable merged game+token mock (LiteGameMock) lives in the // test_common package so downstream consumers can declare it via diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index b1853f84..112e2665 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -17,10 +17,43 @@ two-phase init, a standalone preset, game-side call helpers). | --- | --- | | Self-bound: the embedding contract is the game | No stored game address, no registry, no `game_id` resolution, no SRC5 probes on mint; `game_address()` returns `get_contract_address()` (kept as a view for ecosystem consumers); `mint`'s `game_address` parameter survives for ABI parity and must equal the contract's own address | | No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads | -| Token id layout is canonical | Reuses `token::structs::pack_token_id` (251-bit) bit-for-bit; unused fields (`game_id`, `objective_id`, `has_context`, `paymaster`, `metadata`) are written as zero | +| Token id layout is lite-native | `token_lite::packing::pack_lite_token_id` (251-bit) — its OWN layout, not the full token's (`token::structs` stays untouched, serving legacy denshokan). Indexers must branch their decoder by contract generation | | `mint` is ABI-compatible with `IMinigameToken::mint` | Existing call sites and the `minigame::mint` helper work unchanged; unsupported params are rejected loudly, never silently ignored | | Game contract is the authority | Games gate dead/finished runs themselves and call `refresh_metadata` (ERC-4906) after actions | +## Token ID Layout (lite-native, 251 bits) + +Defined in `packing.cairo` (`pack_lite_token_id` / `unpack_lite_token_id` + +per-field helpers, DivRem-chain style shared with `token::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 `OptionalMinter::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-122 | reserved | 97 | component-owned, ALWAYS packed as zero | + +**Reserved-region ownership contract:** bits [26-122] of the high half belong +to the component. They are always packed as zero — there is no pack parameter +and no public unpack accessor. Future fields (protocol- or game-facing) are +carved from this region later; since every id minted under this layout +provably decodes the region as 0, any future field reads as 0 ("absent") on +all existing ids, making carve-outs non-breaking by construction. Do not stamp +data into these bits from outside the component. + ## Interface (IMinigameTokenLite) **Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7` @@ -34,7 +67,7 @@ full-token id (and then query `game_registry_address()`) accept a lite token; | Method | Cost | Notes | | --- | --- | --- | | `mint(...)` | 1 minter-map read (warm), optional name write, ERC721 mint | Same 15-arg signature as the full token | -| `mint_batch_recipients(...)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | ABI-compatible with the full token; same global salt counter (`salt + sum(counts) - 1 <= 0x3FF`) | +| `mint_batch_recipients(...)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | ABI-compatible with the full token; global salt counter over the lite 16-bit field (`salt + sum(counts) - 1 <= 0xFFFF`) | | `assert_owner_and_playable(token_id, expected_owner)` | 1 storage read (owner) | Combined guard — replaces `owner_of` + `assert_is_playable` (two calls) with one | | `is_playable` / `assert_is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | | `token_metadata`, `settings_id`, `minted_by`, `is_soulbound` | 0 storage reads | Pure unpack of the token id | @@ -50,7 +83,8 @@ views, objectives/settings/context/renderer/skills/enumerable surfaces. Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl (`MinterComponent::MinterOptionalImpl` — minter ids gate reward claims in consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` -via `unpack_soulbound` — pure, no storage). The embedding contract is the game: +via `token_lite::packing::unpack_soulbound` — pure, no storage; NOT the +full token's `unpack_soulbound`, which reads a different bit position). The embedding contract is the game: it implements `IMinigameTokenData` (score/game_over) itself and calls the component's guards (`assert_owner_and_playable`) and `refresh_metadata` internally — the former `minigame::lite::{pre_action, post_action}` diff --git a/packages/embeddable_game_standard/src/token_lite/packing.cairo b/packages/embeddable_game_standard/src/token_lite/packing.cairo new file mode 100644 index 00000000..6a2f1944 --- /dev/null +++ b/packages/embeddable_game_standard/src/token_lite/packing.cairo @@ -0,0 +1,272 @@ +// ============================================================================== +// LITE PACKED TOKEN ID - Embeds immutable data directly in the token_id (felt252) +// ============================================================================== +// +// Lite-native u128-aligned bit layout (251 bits, no field straddles the u128 +// boundary). This layout is OWNED by the lite token and is deliberately NOT the +// full token's `token::structs::pack_token_id` layout — the full layout serves +// legacy denshokan and keeps its bit positions untouched; the lite token drops +// the fields it never writes (game_id, objective_id, has_context, paymaster, +// metadata) and widens the ones it actually uses (settings_id, salt). +// 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-122 | reserved | 97 bits | component-owned, ALWAYS zero | +// Total: 128 + 123 = 251 bits (max for felt252) +// +// Max value: (2^123 - 1) * 2^128 + (2^128 - 1) = 2^251 - 1 < P (Stark prime) +// +// RESERVED REGION CONTRACT: bits [26-122] of the high half are owned by the +// component and are ALWAYS packed as zero — there is no pack parameter and no +// public unpack accessor for them. Future fields (protocol- or game-facing) +// are carved from this region later; because every id minted under this layout +// provably decodes the region as 0, any future field decodes as 0 ("absent") +// on all existing ids, making such carve-outs non-breaking by construction. +// +// 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. +// +// All DivRem operations use native u128_safe_divmod Sierra hints for ~64% gas +// savings compared to u256 mask+divide unpacking. + +use game_components_interfaces::structs::token::{Lifecycle, TokenMetadata}; +// Shared with the full token: extracting the last 10 bits of the tx hash is +// layout-independent. +pub use crate::token::structs::extract_tx_hash_bits; + +/// Data structure representing the lite packed token ID fields (for convenience). +/// The reserved region (high bits 26-122) is deliberately absent — it is +/// component-owned, always zero, and has no accessor. +#[derive(Copy, Drop, Serde)] +pub struct LitePackedTokenId { + 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 +} + +/// NonZero constants for DivRem-based unpacking. +/// Each constant is a power of 2 matching a field width. +/// DivRem extracts field (remainder) and shifts (quotient) in one operation. +mod nz128 { + pub const TWO_POW_10: NonZero = 0x400; + pub const TWO_POW_16: NonZero = 0x10000; + pub const TWO_POW_25: NonZero = 0x2000000; + pub const TWO_POW_26: NonZero = 0x4000000; + pub const TWO_POW_35: NonZero = 0x800000000; +} + +/// Packs lite token metadata into a felt252 token_id using the lite-native +/// 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) | reserved(97, always zero) = 123 bits +#[inline(always)] +pub fn pack_lite_token_id( + minted_at: u64, + start_delay: u32, + end_delay: u32, + settings_id: u32, + minted_by: u64, + soulbound: bool, + tx_hash: u16, + salt: u16, +) -> felt252 { + // Validate all fields fit within their bit allocations + assert!(minted_at <= 0x7FFFFFFFF, "LitePackedTokenId: minted_at exceeds 35-bit limit"); + assert!(start_delay <= 0x1FFFFFF, "LitePackedTokenId: start_delay exceeds 25-bit limit"); + assert!(end_delay <= 0x1FFFFFF, "LitePackedTokenId: end_delay exceeds 25-bit limit"); + assert!(settings_id <= 0xFFFF, "LitePackedTokenId: settings_id exceeds 16-bit limit"); + assert!(minted_by <= 0x3FFFFFF, "LitePackedTokenId: minted_by exceeds 26-bit limit"); + + // Low u128: minted_at(35) + start_delay(25) + end_delay(25) + settings_id(16) + // + minted_by(26) + soulbound(1) = 128 bits + let soulbound_u128: u128 = if soulbound { + 1 + } else { + 0 + }; + + let low: u128 = Into::::into(minted_at) + + Into::::into(start_delay) * 0x800000000_u128 // shift 35 + + Into::::into(end_delay) * 0x1000000000000000_u128 // shift 60 + + Into::::into(settings_id) * 0x2000000000000000000000_u128 // shift 85 + + Into::::into(minted_by) * 0x20000000000000000000000000_u128 // shift 101 + + soulbound_u128 * 0x80000000000000000000000000000000_u128; // shift 127 + + // High u128: tx_hash(10) + salt(16) = 26 bits; bits 26-122 (reserved) are + // never written — always zero. salt is a u16 written into a 16-bit field, + // so unlike the full token's 10-bit salt it needs no mask. + let high: u128 = Into::::into(tx_hash & 0x3FF) + + Into::::into(salt) * 0x400_u128; // shift 10 + + let packed = u256 { low, high }; + packed.try_into().unwrap() +} + +/// Unpacks a lite token_id into its component fields using DivRem chains on each +/// u128 half. The reserved region (high quotient past salt) is discarded. +#[inline(always)] +pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { + let packed: u256 = token_id.into(); + let low = packed.low; + let high = packed.high; + + // Unpack low u128: minted_at(35) | start_delay(25) | end_delay(25) + // | settings_id(16) | minted_by(26) | soulbound(1) + let (hi, minted_at) = DivRem::div_rem(low, nz128::TWO_POW_35); + let (hi, start_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, end_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, settings_id) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (soulbound_u128, minted_by) = DivRem::div_rem(hi, nz128::TWO_POW_26); + + // Unpack high u128: tx_hash(10) | salt(16) | reserved(97, dropped) + let (hi, tx_hash) = DivRem::div_rem(high, nz128::TWO_POW_10); + let (_, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); + + LitePackedTokenId { + minted_at: minted_at.try_into().unwrap(), + start_delay: start_delay.try_into().unwrap(), + end_delay: end_delay.try_into().unwrap(), + settings_id: settings_id.try_into().unwrap(), + minted_by: minted_by.try_into().unwrap(), + soulbound: soulbound_u128 == 1, + tx_hash: tx_hash.try_into().unwrap(), + salt: salt.try_into().unwrap(), + } +} + +/// Helper to unpack just minted_at from a lite 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 lite token_id +#[inline(always)] +pub fn unpack_start_delay(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + let (_, start_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + start_delay.try_into().unwrap() +} + +/// Helper to unpack just end_delay from a lite token_id +#[inline(always)] +pub fn unpack_end_delay(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (_, end_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + end_delay.try_into().unwrap() +} + +/// Helper to unpack just settings_id from a lite token_id +#[inline(always)] +pub fn unpack_settings_id(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (_, settings_id) = DivRem::div_rem(hi, nz128::TWO_POW_16); + settings_id.try_into().unwrap() +} + +/// Helper to unpack just minted_by from a lite token_id +#[inline(always)] +pub fn unpack_minted_by(token_id: felt252) -> u64 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (_, minted_by) = DivRem::div_rem(hi, nz128::TWO_POW_26); + minted_by.try_into().unwrap() +} + +/// Helper to unpack the soulbound flag from a lite token_id +#[inline(always)] +pub fn unpack_soulbound(token_id: felt252) -> bool { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (soulbound_u128, _) = DivRem::div_rem(hi, nz128::TWO_POW_26); + soulbound_u128 == 1 +} + +/// Helper to unpack tx_hash from a lite 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 lite token_id (client-provided collision protection) +#[inline(always)] +pub fn unpack_salt(token_id: felt252) -> u16 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + let (_, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); + salt.try_into().unwrap() +} + +/// Convert LitePackedTokenId to the shared TokenMetadata struct. +/// +/// The lite token has no mutable state and never writes the full token's +/// extension fields, so `game_id`, `objective_id`, `has_context`, `paymaster`, +/// `metadata`, `game_over`, `completed_objective` and `completed_at` are all +/// zeroed. The lifecycle is reconstructed from minted_at + delays with the same +/// rule as the full token: end_delay == 0 means "no expiration" (end == 0). +#[inline(always)] +pub fn to_token_metadata(packed: LitePackedTokenId) -> 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: false, + objective_id: 0, + paymaster: false, + metadata: 0, + } +} diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index 8a978643..9c287155 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -3,18 +3,21 @@ use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTra 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::extensions::minter::interface::{ IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, }; use crate::token::interface::IMINIGAME_TOKEN_ID; -use crate::token::structs::{ - MintBatchRecipient, unpack_game_id, unpack_objective_id, unpack_salt, unpack_token_id, -}; +use crate::token::structs::MintBatchRecipient; use crate::token_lite::interface::{ IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, }; +use crate::token_lite::packing::{ + unpack_end_delay, unpack_lite_token_id, unpack_minted_at, unpack_minted_by, unpack_salt, + unpack_settings_id, unpack_soulbound, unpack_start_delay, unpack_tx_hash, +}; use crate::token_lite::token_lite_component::CoreTokenLiteComponent; fn addr(value: felt252) -> ContractAddress { @@ -130,18 +133,20 @@ fn test_mint_packs_expected_fields() { 7, ); - let packed = unpack_token_id(token_id); - assert!(packed.game_id == 0, "game_id must be 0 for single game"); + let packed = unpack_lite_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.objective_id == 0, "objective_id must be 0"); assert!(packed.soulbound, "soulbound flag should be set"); - assert!(!packed.has_context, "has_context must be 0"); - assert!(!packed.paymaster, "paymaster must be 0"); + assert!(packed.minted_by == 1, "First minter should pack id 1"); assert!(packed.salt == 7, "salt mismatch"); - assert!(packed.metadata == 0, "metadata must be 0"); + + // Reserved region (high bits 26-122) is component-owned and must be + // provably zero on every minted id: only tx_hash(10) + salt(16) occupy + // the high half. + let raw: u256 = token_id.into(); + assert!(raw.high / 0x4000000 == 0, "reserved bits must be zero"); // 2^26 // Views resolve from the packed id / minter map assert!(token.settings_id(token_id) == 42, "settings_id view mismatch"); @@ -720,11 +725,24 @@ fn test_mint_batch_recipients_counts_owners_and_salts() { #[test] #[should_panic( - expected: "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 1023)", + expected: "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 65535)", )] fn test_mint_batch_recipients_rejects_salt_overflow() { let (token, _, _) = deploy_token_lite(); - batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 4 }], 1021); + // 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_lite(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 65533 + 3 - 1 = 65535 == 0xFFFF — exactly fills the widened 16-bit + // field (would have overflowed the full 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] @@ -799,22 +817,89 @@ fn test_assert_game_registered_rejects_game_not_paired_with_lite_token() { } // ================================================================================================ -// PACKING PARITY HELPERS +// LITE PACKING — LAYOUT AND HELPERS // ================================================================================================ #[test] fn test_helper_unpackers_agree_with_full_unpack() { let (token, _, _) = deploy_token_lite(); 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, ); - // The lite token reuses the canonical 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_game_id(token_id) == packed.game_id, "game_id helper mismatch"); - assert!(unpack_objective_id(token_id) == packed.objective_id, "objective helper mismatch"); - assert!(packed.game_id == 0 && packed.objective_id == 0, "lite invariants"); + // Token ids use the lite-native 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_lite_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!(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 +/// lite layout — low: minted_at | start_delay<<35 | end_delay<<60 | +/// settings_id<<85 | minted_by<<101 | soulbound<<127; high: tx_hash | salt<<10; +/// reserved bits [26-122] of the high half all zero. +#[test] +fn test_lite_layout_bit_positions_exact() { + let (token, _, _) = deploy_token_lite(); + 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 = mint_basic( + token, + Option::None, + Option::Some(0xABCD), + Option::Some(2000), + Option::Some(5000), + ALICE(), + true, + 0x1234, + ); + + // 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. + 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; // tx_hash | salt << 10 + let expected: felt252 = u256 { low: expected_low, high: expected_high }.try_into().unwrap(); + assert!(token_id == expected, "lite layout bit positions must match the documented table"); +} + +#[test] +fn test_mint_accepts_settings_id_at_16_bit_boundary() { + let (token, _, _) = deploy_token_lite(); + 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: "LitePackedTokenId: settings_id exceeds 16-bit limit")] +fn test_mint_rejects_settings_id_over_16_bits() { + let (token, _, _) = deploy_token_lite(); + start_cheat_block_timestamp(token.contract_address, 1000); + // 0x10000 fit the full token's 30-bit field but exceeds the lite 16-bit + // field — must now be rejected at mint. + mint_basic( + token, Option::None, Option::Some(0x10000), Option::None, Option::None, ALICE(), false, 0, + ); } diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index 17c08d5d..8595a418 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -28,11 +28,14 @@ /// * **Settings/objective validation on mint** — minters pass an /// admin-configured `settings_id`; the game validates it at play time. /// -/// What is kept bit-identical: the 251-bit `pack_token_id` layout. Existing -/// integrations unpack `settings_id`/`minted_by`/lifecycle from the id and -/// indexers decode it; the lite token writes zeros into `game_id`, -/// `objective_id`, `has_context`, `paymaster` and `metadata` rather than -/// reshuffling bits. +/// Token ids use the lite-native 251-bit layout in `token_lite::packing` — +/// NOT the full token's `token::structs::pack_token_id` layout (which stays +/// untouched, serving legacy denshokan). The lite layout drops the fields the +/// lite token never writes (game_id, objective_id, has_context, paymaster, +/// metadata), widens `settings_id` to 16 bits and `salt` to 16 bits, and +/// consolidates every spare bit into one component-owned, always-zero +/// reserved region in the high half. Indexers must branch their token-id +/// decoder by contract generation. #[starknet::component] pub mod CoreTokenLiteComponent { use core::num::traits::Zero; @@ -48,13 +51,13 @@ pub mod CoreTokenLiteComponent { ContractAddress, get_block_timestamp, get_caller_address, get_contract_address, get_tx_info, }; use crate::token::interface::IMINIGAME_TOKEN_ID; - use crate::token::structs::{ - GameContextDetails, MintBatchRecipient, TokenMetadata, TokenMutableState, - extract_tx_hash_bits, pack_token_id, to_token_metadata, unpack_minted_by, - unpack_settings_id, unpack_soulbound, unpack_token_id, - }; + use crate::token::structs::{GameContextDetails, MintBatchRecipient, TokenMetadata}; use crate::token::token::{LifecycleTrait, token_state}; use crate::token::traits::OptionalMinter; + use crate::token_lite::packing::{ + extract_tx_hash_bits, pack_lite_token_id, to_token_metadata, unpack_lite_token_id, + unpack_minted_by, unpack_settings_id, unpack_soulbound, + }; #[storage] pub struct Storage { @@ -87,13 +90,10 @@ pub mod CoreTokenLiteComponent { fn token_metadata( self: @ComponentState, token_id: felt252, ) -> TokenMetadata { - let packed = unpack_token_id(token_id); // No mutable state exists; the game contract is authoritative for - // game_over / objective completion. - let empty_state = TokenMutableState { - game_over: false, completed_objective: false, completed_at: 0, - }; - to_token_metadata(packed, empty_state) + // game_over / objective completion — the returned metadata reports + // game_over/completed_objective/completed_at as false/0 always. + to_token_metadata(unpack_lite_token_id(token_id)) } fn is_playable(self: @ComponentState, token_id: felt252) -> bool { @@ -233,20 +233,18 @@ pub mod CoreTokenLiteComponent { let mut contract_self = self.get_contract_mut(); let minted_by = MinterOpt::add_minter(ref contract_self, caller); - let final_token_id = pack_token_id( - 0, // game_id: always 0 — single game - minted_by, - settings_id.unwrap_or(0), + // settings_id keeps its Option ABI type for compat; the pack + // asserts the value fits the lite layout's 16-bit field. Likewise + // minted_by (u64 from OptionalMinter::add_minter) must fit 26 bits. + let final_token_id = pack_lite_token_id( current_time, start_delay, end_delay, - 0, // objective_id + settings_id.unwrap_or(0), + minted_by, soulbound, - false, // has_context - false, // paymaster tx_hash_bits, salt, - 0 // metadata ); if let Option::Some(name) = player_name { @@ -266,9 +264,9 @@ pub mod CoreTokenLiteComponent { /// lite deployment; the same unsupported-parameter rules as `mint` apply. /// /// Salt is a single global counter across the batch (`salt + i` for - /// `i in 0..sum(counts)`), identical to the full token: token ids do not - /// encode the recipient, so salts must be globally unique within the tx — - /// `salt + sum(counts) - 1 <= 0x3FF` (10-bit field). + /// `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 lite layout's 16-bit field). /// /// Versus calling `mint` per token, the lifecycle math, tx-info read, game /// check and minter registration are hoisted and paid once for the batch. @@ -319,8 +317,8 @@ pub mod CoreTokenLiteComponent { } let max_salt: u32 = salt.into() + total_tokens - 1; assert!( - max_salt <= 0x3FF, - "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 1023)", + max_salt <= 0xFFFF, + "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 65535)", ); // Hoisted per-batch work: lifecycle math (same rules and rationale as @@ -364,20 +362,15 @@ pub mod CoreTokenLiteComponent { let mut k: u16 = 0; while k < count { - let final_token_id = pack_token_id( - 0, // game_id: always 0 — single game - minted_by, - validated_settings_id, + let final_token_id = pack_lite_token_id( current_time, start_delay, end_delay, - 0, // objective_id + validated_settings_id, + minted_by, soulbound, - false, // has_context - false, // paymaster tx_hash_bits, salt + salt_offset, - 0 // metadata ); if let Option::Some(name) = player_name { @@ -465,11 +458,7 @@ pub mod CoreTokenLiteComponent { /// 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 packed = unpack_token_id(token_id); - let empty_state = TokenMutableState { - game_over: false, completed_objective: false, completed_at: 0, - }; - let metadata = to_token_metadata(packed, empty_state); + let metadata = to_token_metadata(unpack_lite_token_id(token_id)); let current_time = get_block_timestamp(); let lifecycle = metadata.lifecycle; assert!( diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo index b28d2d6f..a7ce03e1 100644 --- a/packages/interfaces/src/token/lite.cairo +++ b/packages/interfaces/src/token/lite.cairo @@ -7,6 +7,14 @@ // mutable state: everything except `player_name` is unpacked from the token id // itself, so every view is pure felt arithmetic plus at most one storage read. // +// Token ids use the lite-native 251-bit layout (see +// `game_components_embeddable_game_standard::token_lite::packing`), NOT the +// full token's layout: it drops the fields the lite token never writes, widens +// settings_id and salt to 16 bits each, and consolidates all spare bits into +// one component-owned, always-zero reserved region in the high half from which +// future fields are carved. Indexers must branch their token-id decoder by +// contract generation. +// // `mint` keeps the exact `IMinigameToken::mint` signature (same selector, same // calldata layout) so existing call sites and the `minigame::mint` helper work // unchanged against a lite deployment. Parameters the lite token does not @@ -64,7 +72,9 @@ pub trait IMinigameTokenLite { /// survives for ABI parity and to catch caller misconfiguration; /// `objective_id`, `context`, `client_url`, /// `renderer_address`, `skills_address` must be `None`, `paymaster` must be - /// `false`, and `metadata` must be `0`. + /// `false`, and `metadata` must be `0`. `settings_id` keeps its `Option` + /// ABI type for compat, but the value must fit the lite layout's 16-bit + /// field (`<= 0xFFFF`) or the mint reverts. fn mint( ref self: TState, game_address: ContractAddress, @@ -86,7 +96,8 @@ pub trait IMinigameTokenLite { /// Batch mint with per-recipient counts. Signature-compatible with /// `IMinigameToken::mint_batch_recipients`; the same unsupported-parameter /// rules as `mint` apply, and salt is a single global counter across the - /// batch (`salt + sum(counts) - 1 <= 0x3FF`). + /// batch (`salt + sum(counts) - 1 <= 0xFFFF` — the lite layout's 16-bit + /// salt field). fn mint_batch_recipients( ref self: TState, game_address: ContractAddress, diff --git a/packages/test_common/src/mocks/lite_game_mock.cairo b/packages/test_common/src/mocks/lite_game_mock.cairo index b1200f68..4bc595f1 100644 --- a/packages/test_common/src/mocks/lite_game_mock.cairo +++ b/packages/test_common/src/mocks/lite_game_mock.cairo @@ -4,7 +4,8 @@ // // The contract wires: // * ERC721 + SRC5 + CoreTokenLiteComponent + MinterComponent, with the -// soulbound transfer guard in `before_update` (pure `unpack_soulbound`). +// soulbound transfer guard in `before_update` (pure `unpack_soulbound` +// from the lite-native `token_lite::packing` layout). // * `IMinigame` views that all return the contract's own address, plus // `mint_game`/`mint_game_batch` delegating to the embedded lite token. // * `IMinigameTokenData` from local maps, with test setters `set_score` / @@ -39,7 +40,7 @@ pub mod LiteGameMock { use game_components_embeddable_game_standard::minigame::minigame as minigame_libs; use game_components_embeddable_game_standard::minigame::structs::MintGameParams; use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; - use game_components_embeddable_game_standard::token::structs::unpack_soulbound; + use game_components_embeddable_game_standard::token_lite::packing::unpack_soulbound; use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; From 0e369c14ac9221b9db58708f59c7213a69d7352b Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:11:49 -0700 Subject: [PATCH 11/33] =?UTF-8?q?refactor(token=5Flite)!:=20strip=20the=20?= =?UTF-8?q?ABI=20=E2=80=94=20dead=20machinery=20out,=20capability=20and=20?= =?UTF-8?q?read=20views=20stay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strip principle: delete dead machinery and compat shims; keep capability (writes) and cheap client-facing read views. The lite token's external ABI is no longer an IMinigameToken mirror. Removed surface: - mint / mint_batch_recipients trimmed to 7 args: the dead full-token params (game_address, objective_id, context, client_url, renderer_address, skills_address, paymaster, metadata) are gone along with their reject-asserts - game_address / game_registry_address views deleted — consumers SRC5-probe the lite id; metagame::assert_game_registered now probes IMINIGAME_TOKEN_LITE_ID first and falls through to the unchanged full-token registry path - assert_is_playable / assert_owner_and_playable moved off the ABI to InternalTrait — the embedding game's own pre-action guard (zero syscalls); clients read is_playable - refresh_metadata_batch deleted (a multicall of singles) - legacy IMINIGAME_TOKEN_ID SRC5 registration dropped — SRC5 is honest: a lite token does not implement IMinigameToken Kept with identical semantics: token_metadata, is_playable, settings_id, player_name, minted_by, minted_by_address, is_soulbound, update_player_name, refresh_metadata. New interface id (derived over the surface minus refresh_metadata, per the refresh-exclusion convention): IMINIGAME_TOKEN_LITE_ID = 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37 LiteGameMock rewires IMinigame::mint_game/mint_game_batch to the trimmed mint (dead params rejected at the mock) and re-exposes the internal guard for tests. token_lite 40/40, metagame 92/92. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 18 +- .../src/metagame/metagame.cairo | 22 +- .../src/metagame/tests/test_libs.cairo | 5 + .../src/token_lite/AGENTS.md | 58 +-- .../src/token_lite/tests/test_gas_bench.cairo | 22 +- .../token_lite/tests/test_token_lite.cairo | 365 +++--------------- .../src/token_lite/token_lite_component.cairo | 193 +++------ packages/interfaces/src/AGENTS.md | 4 + packages/interfaces/src/token/lite.cairo | 117 +++--- .../src/mocks/lite_game_mock.cairo | 77 +++- 10 files changed, 289 insertions(+), 592 deletions(-) diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index 89c1f083..6c784d76 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -111,7 +111,23 @@ High u128 (123 bits): `tx_hash(10) | salt(16) | reserved(97, always zero)` | 101–126 | minted_by (26-bit minter id) | | | | 127 | soulbound | | | -The reserved region has no pack parameter and no public unpack accessor; future protocol- or game-facing fields are carved from it later, and because every lite id provably decodes it as 0, such carve-outs are non-breaking by construction. The `IMinigameTokenLite` ABI and `IMINIGAME_TOKEN_LITE_ID` are unchanged (the batch salt bound rises to `salt + sum(counts) - 1 <= 0xFFFF`, and `settings_id > 0xFFFF` is now 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 reserved region has no pack parameter and no public unpack accessor; future protocol- or game-facing fields are carved from it later, and because every lite id provably decodes it as 0, such carve-outs are non-breaking by construction. The batch salt bound rises to `salt + sum(counts) - 1 <= 0xFFFF`, and `settings_id > 0xFFFF` is now 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` trimmed to 7 args | `mint(player_name, settings_id, start, end, to, soulbound, salt)` — the dead full-token params (`game_address`, `objective_id`, `context`, `client_url`, `renderer_address`, `skills_address`, `paymaster`, `metadata`) are gone **along with their reject-asserts**; `mint_batch_recipients` trims identically (recipients array in place of `to`) | +| 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`, `player_name` (near-zero-cost client/RPC conveniences) and `update_player_name` (owner-gated capability) stay with identical semantics | +| 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 = 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37` (rederived over the new surface minus `refresh_metadata`, per the refresh-exclusion convention) | + +**Downstream:** SDM dungeons/GameCore and budokan v2 migrate their mint call sites to the 7-arg shape (drop the game-address argument and the eight `None`/`false`/`0` fillers), 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. --- diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 099c9975..60a582c0 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -9,24 +9,32 @@ use game_components_embeddable_game_standard::registry::interface::{ use game_components_embeddable_game_standard::token::interface::{ IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; +use game_components_interfaces::token::lite::IMINIGAME_TOKEN_LITE_ID; +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 /// -/// For registry-backed (multi-game) tokens this asks the registry. For tokens -/// with no registry — a zero `game_registry_address()` — "registered" means -/// the pairing is mutual, and self-bound lite tokens (the game contract IS the -/// token) make that a plain address equality: the game's `token_address()` -/// must be the game itself. This saves a cross-contract `game_address()` read -/// at tournament creation. Previously this path dispatched to the zero -/// address and reverted with CONTRACT_NOT_DEPLOYED for any single-game token. +/// The token is probed via SRC5 first: a token supporting +/// `IMINIGAME_TOKEN_LITE_ID` is a self-bound lite token (the game contract IS +/// the token — lite 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 full +/// token: registry-backed (multi-game) tokens ask the registry, and a zero +/// `game_registry_address()` (single-game full token) again means the mutual +/// pairing is the check. /// /// # Arguments /// * `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 token_src5_dispatcher = ISRC5Dispatcher { contract_address: minigame_token_address }; + if token_src5_dispatcher.supports_interface(IMINIGAME_TOKEN_LITE_ID) { + assert!(minigame_token_address == game_address, "Game is not registered"); + return; + } let minigame_token_dispatcher = IMinigameTokenDispatcher { contract_address: minigame_token_address, }; 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..ce1682c8 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -1380,6 +1380,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 lite token: the SRC5 probe for IMINIGAME_TOKEN_LITE_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 +1398,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 lite 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); diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index 112e2665..b9370222 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -15,11 +15,11 @@ two-phase init, a standalone preset, game-side call helpers). | 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; `game_address()` returns `get_contract_address()` (kept as a view for ecosystem consumers); `mint`'s `game_address` parameter survives for ABI parity and must equal the contract's own address | -| No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads | +| 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 lite token by SRC5 (`IMINIGAME_TOKEN_LITE_ID`) | +| No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads. `player_name` is the only per-token storage (owner-renameable) | | Token id layout is lite-native | `token_lite::packing::pack_lite_token_id` (251-bit) — its OWN layout, not the full token's (`token::structs` stays untouched, serving legacy denshokan). Indexers must branch their decoder by contract generation | -| `mint` is ABI-compatible with `IMinigameToken::mint` | Existing call sites and the `minigame::mint` helper work unchanged; unsupported params are rejected loudly, never silently ignored | -| Game contract is the authority | Games gate dead/finished runs themselves and call `refresh_metadata` (ERC-4906) after actions | +| Strip principle: machinery deleted, capability + read views kept | The ABI is NOT `IMinigameToken`-compatible: the full token's dead mint params (game_address, objective, context, client_url, renderer, skills, paymaster, metadata) are gone along with their reject-asserts, 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 | +| 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 (lite-native, 251 bits) @@ -56,24 +56,35 @@ data into these bits from outside the component. ## Interface (IMinigameTokenLite) -**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7` +**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37` +(derived over the trait minus `refresh_metadata`, mirroring the refresh +exclusion from `IMINIGAME_TOKEN_ID`) Defined in `packages/interfaces/src/token/lite.cairo`. The no-arg -`initializer()` registers both `IMINIGAME_TOKEN_LITE_ID` and -`IMINIGAME_TOKEN_ID` — the latter so ecosystem consumers that hard-assert the -full-token id (and then query `game_registry_address()`) accept a lite token; -`game_registry_address()` always returns zero. +`initializer()` registers ONLY `IMINIGAME_TOKEN_LITE_ID` — SRC5 is honest: a +lite token does not implement `IMinigameToken` and does not advertise the +legacy id. Consumers branch on the lite id instead of resolving +registry/game-address views. | Method | Cost | Notes | | --- | --- | --- | -| `mint(...)` | 1 minter-map read (warm), optional name write, ERC721 mint | Same 15-arg signature as the full token | -| `mint_batch_recipients(...)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | ABI-compatible with the full token; global salt counter over the lite 16-bit field (`salt + sum(counts) - 1 <= 0xFFFF`) | -| `assert_owner_and_playable(token_id, expected_owner)` | 1 storage read (owner) | Combined guard — replaces `owner_of` + `assert_is_playable` (two calls) with one | -| `is_playable` / `assert_is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | -| `token_metadata`, `settings_id`, `minted_by`, `is_soulbound` | 0 storage reads | Pure unpack of the token id | +| `mint(player_name, settings_id, start, end, to, soulbound, salt)` | 1 minter-map read (warm), optional name write, ERC721 mint | Trimmed 7-arg shape — no game address (self-bound), none of the full token's dead params | +| `mint_batch_recipients(player_name, settings_id, start, end, recipients, soulbound, salt)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | Global salt counter over the lite 16-bit field (`salt + sum(counts) - 1 <= 0xFFFF`) | +| `is_playable` | 0 storage reads | Lifecycle window only — no game_over latch | +| `token_metadata`, `settings_id`, `minted_by`, `is_soulbound` | 0 storage reads | Pure unpack of the token id — kept as client/RPC conveniences (also derivable from the documented id layout) | | `player_name`, `minted_by_address` | 1 storage read | | -| `refresh_metadata(_batch)` | event only | Same advisory/no-existence-check semantics as the full token | -| `update_player_name` | owner-gated write | | +| `refresh_metadata` | event only | Same advisory/no-existence-check semantics as the full token | +| `update_player_name` | owner-gated write | Emits `MetadataUpdate` | + +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 the lite 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, objectives/settings/context/renderer/skills/enumerable surfaces. @@ -86,19 +97,20 @@ consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` via `token_lite::packing::unpack_soulbound` — pure, no storage; NOT the full token's `unpack_soulbound`, which reads a different bit position). The embedding contract is the game: it implements `IMinigameTokenData` (score/game_over) itself and calls the -component's guards (`assert_owner_and_playable`) and `refresh_metadata` -internally — the former `minigame::lite::{pre_action, post_action}` -cross-contract helpers were deleted with the separate-token shape. +component's internal guard (`InternalTrait::assert_owner_and_playable`) and +`refresh_metadata` internally — the former `minigame::lite::{pre_action, +post_action}` cross-contract helpers were deleted with the separate-token +shape. See `test_common/src/mocks/lite_game_mock.cairo` (`LiteGameMock`) 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` accepts -registry-less tokens (zero `game_registry_address()`) by asserting -`token_address == game_address` — with self-binding the pairing is a plain -address equality, no cross-contract `game_address()` read. +For metagames: `metagame::metagame::assert_game_registered` SRC5-probes the +game's token for `IMINIGAME_TOKEN_LITE_ID` first — a lite token means +"registered" is the self-binding equality `token_address == game_address`; +otherwise the full-token registry path runs unchanged. ## Testing diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo index ee7e7ae1..79bba74b 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo @@ -17,6 +17,9 @@ // denshokan does, adding two storage writes per mint and per transfer on // top of the full-token numbers. +use game_components_test_common::mocks::lite_game_mock::{ + ILiteGameMockDispatcher, ILiteGameMockDispatcherTrait, +}; use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use snforge_std::{ CheatSpan, ContractClassTrait, DeclareResultTrait, cheat_caller_address, declare, @@ -133,24 +136,18 @@ fn setup_full() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddre ) } -fn mint_lite(token: IMinigameTokenLiteDispatcher, game: ContractAddress, salt: u16) -> felt252 { +fn mint_lite(token: IMinigameTokenLiteDispatcher, _game: ContractAddress, salt: u16) -> felt252 { + // Trimmed 7-arg lite mint — no game address (self-bound), none of the + // full token's dead parameters. 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, ) } @@ -227,16 +224,19 @@ fn bench_full_mint_x10() { // ================================================================================================ // PER-ACTION GUARD — full: owner_of + assert_is_playable (2 calls, as -// death-mountain's game_core does today) vs lite: assert_owner_and_playable (1) +// death-mountain's game_core does today) vs lite: 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_lite_guard_x10() { let (token, _, game) = setup_lite(); let token_id = mint_lite(token, game, 0); + let game_mock = ILiteGameMockDispatcher { contract_address: token.contract_address }; let mut i: u32 = 0; while i < 10 { - token.assert_owner_and_playable(token_id, ALICE()); + game_mock.assert_owner_and_playable(token_id, ALICE()); i += 1; } } diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index 9c287155..c8c989b5 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -1,3 +1,6 @@ +use game_components_test_common::mocks::lite_game_mock::{ + ILiteGameMockDispatcher, ILiteGameMockDispatcherTrait, +}; use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use snforge_std::{ @@ -57,9 +60,16 @@ fn deploy_token_lite() -> ( ) } -/// Mint with lifecycle only — every unsupported parameter at its required -/// neutral value, mirroring how death-mountain-style dungeons call mint. The -/// "game address" is the token's own address (self-bound). +/// 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: IMinigameTokenLiteDispatcher) -> ILiteGameMockDispatcher { + ILiteGameMockDispatcher { contract_address: token.contract_address } +} + +/// Mint with the trimmed 7-arg shape — no game address (the token IS the +/// game), none of the full token's dead parameters. fn mint_basic( token: IMinigameTokenLiteDispatcher, player_name: Option, @@ -70,24 +80,7 @@ fn mint_basic( soulbound: bool, salt: u16, ) -> felt252 { - token - .mint( - token.contract_address, - player_name, - settings_id, - start, - end, - Option::None, // objective_id - Option::None, // context - Option::None, // client_url - Option::None, // renderer_address - Option::None, // skills_address - to, - soulbound, - false, // paymaster - salt, - 0 // metadata - ) + token.mint(player_name, settings_id, start, end, to, soulbound, salt) } // ================================================================================================ @@ -98,18 +91,14 @@ fn mint_basic( fn test_deployment_and_interfaces() { let (token, erc721, _) = deploy_token_lite(); - assert!( - token.game_address() == token.contract_address, - "game_address must be the contract itself (self-bound)", - ); - assert!(token.game_registry_address() == addr(0), "Registry address should always be zero"); assert!(erc721.name() == "LiteToken", "Name mismatch"); assert!(erc721.symbol() == "LITE", "Symbol mismatch"); let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; assert!(src5.supports_interface(IMINIGAME_TOKEN_LITE_ID), "Should register lite interface id"); - // Legacy id registered so MinigameComponent::initializer accepts a lite token - assert!(src5.supports_interface(IMINIGAME_TOKEN_ID), "Should register full token id"); + // SRC5 is honest: a lite token does NOT implement IMinigameToken and no + // longer advertises the legacy full-token id. + assert!(!src5.supports_interface(IMINIGAME_TOKEN_ID), "Must NOT advertise the full token id"); } // ================================================================================================ @@ -224,204 +213,9 @@ fn test_mint_unique_ids_by_salt_and_minter() { } // ================================================================================================ -// MINT — REJECTED PARAMETERS +// MINT — LIFECYCLE VALIDATION // ================================================================================================ -#[test] -#[should_panic(expected: "MinigameTokenLite: objectives not supported")] -fn test_mint_rejects_objective_id() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some(1), - Option::None, - Option::None, - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: context not supported")] -fn test_mint_rejects_context() { - let (token, _, _) = deploy_token_lite(); - let context = crate::token::structs::GameContextDetails { - name: "ctx", description: "ctx", id: Option::None, context: array![].span(), - }; - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some(context), - Option::None, - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: client_url not supported")] -fn test_mint_rejects_client_url() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some("https://x.test"), - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: per-token renderer not supported")] -fn test_mint_rejects_renderer() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some(addr('RENDERER')), - Option::None, - ALICE(), - false, - false, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: skills not supported")] -fn test_mint_rejects_skills() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some(addr('SKILLS')), - ALICE(), - false, - false, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: paymaster flag not supported")] -fn test_mint_rejects_paymaster() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - ALICE(), - false, - true, - 0, - 0, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: metadata field not supported")] -fn test_mint_rejects_metadata() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 5, - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: Game address does not match configured game")] -fn test_mint_rejects_wrong_game_address() { - let (token, _, _) = deploy_token_lite(); - token - .mint( - addr('OTHER_GAME'), - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - ALICE(), - false, - false, - 0, - 0, - ); -} - #[test] #[should_panic(expected: "MinigameTokenLite: Lifecycle end must be in the future and after start")] fn test_mint_rejects_past_end() { @@ -456,6 +250,7 @@ fn test_mint_rejects_start_after_end() { #[test] fn test_playability_follows_lifecycle_window() { let (token, _, _) = deploy_token_lite(); + let game = game_of(token); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_basic( @@ -473,8 +268,8 @@ fn test_playability_follows_lifecycle_window() { start_cheat_block_timestamp(token.contract_address, 2000); assert!(token.is_playable(token_id), "Playable at window start"); - token.assert_is_playable(token_id); - token.assert_owner_and_playable(token_id, ALICE()); + // 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"); @@ -491,21 +286,25 @@ fn test_immortal_token_always_playable() { 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: "MinigameTokenLite: Token is not playable - game has expired")] -fn test_assert_is_playable_panics_after_expiry() { +fn test_guard_panics_after_expiry() { let (token, _, _) = deploy_token_lite(); 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); - token.assert_is_playable(token_id); + game_of(token).assert_owner_and_playable(token_id, ALICE()); } #[test] #[should_panic(expected: "MinigameTokenLite: Token is not playable - game has not started")] -fn test_assert_is_playable_panics_before_start() { +fn test_guard_panics_before_start() { let (token, _, _) = deploy_token_lite(); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_basic( @@ -518,34 +317,34 @@ fn test_assert_is_playable_panics_before_start() { false, 0, ); - token.assert_is_playable(token_id); + game_of(token).assert_owner_and_playable(token_id, ALICE()); } #[test] #[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] -fn test_assert_owner_and_playable_rejects_wrong_owner() { +fn test_guard_rejects_wrong_owner() { let (token, _, _) = deploy_token_lite(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); - token.assert_owner_and_playable(token_id, BOB()); + game_of(token).assert_owner_and_playable(token_id, BOB()); } #[test] #[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] -fn test_assert_owner_and_playable_rejects_nonexistent_token() { +fn test_guard_rejects_nonexistent_token() { let (token, _, _) = deploy_token_lite(); - token.assert_owner_and_playable(12345, ALICE()); + game_of(token).assert_owner_and_playable(12345, ALICE()); } #[test] #[should_panic(expected: "MinigameTokenLite: Expected owner cannot be zero")] -fn test_assert_owner_and_playable_rejects_zero_owner() { +fn test_guard_rejects_zero_owner() { let (token, _, _) = deploy_token_lite(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); - token.assert_owner_and_playable(token_id, addr(0)); + game_of(token).assert_owner_and_playable(token_id, addr(0)); } // ================================================================================================ @@ -600,44 +399,6 @@ fn test_refresh_metadata_emits_event() { ); } -#[test] -fn test_refresh_metadata_batch_emits_events() { - let (token, _, _) = deploy_token_lite(); - 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, - ); - - let mut spy = spy_events(); - token.refresh_metadata_batch(array![id_a, id_b].span()); - spy - .assert_emitted( - @array![ - ( - token.contract_address, - CoreTokenLiteComponent::Event::MetadataUpdate( - CoreTokenLiteComponent::MetadataUpdate { token_id: id_a.into() }, - ), - ), - ( - token.contract_address, - CoreTokenLiteComponent::Event::MetadataUpdate( - CoreTokenLiteComponent::MetadataUpdate { token_id: id_b.into() }, - ), - ), - ], - ); -} - -#[test] -#[should_panic(expected: "MinigameTokenLite: token_ids array cannot be empty")] -fn test_refresh_metadata_batch_rejects_empty() { - let (token, _, _) = deploy_token_lite(); - token.refresh_metadata_batch(array![].span()); -} - #[test] fn test_update_player_name_by_owner() { let (token, _, _) = deploy_token_lite(); @@ -669,21 +430,13 @@ fn batch_neutral( ) -> Array { token .mint_batch_recipients( - token.contract_address, Option::Some('bench'), Option::Some(5), Option::None, Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, recipients, false, - false, salt, - 0, ) } @@ -759,51 +512,27 @@ fn test_mint_batch_recipients_rejects_zero_count() { batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 0 }], 0); } -#[test] -#[should_panic(expected: "MinigameTokenLite: context not supported")] -fn test_mint_batch_recipients_rejects_context() { - let (token, _, _) = deploy_token_lite(); - let context = crate::token::structs::GameContextDetails { - name: "ctx", description: "ctx", id: Option::None, context: array![].span(), - }; - token - .mint_batch_recipients( - token.contract_address, - Option::None, - Option::None, - Option::None, - Option::None, - Option::None, - Option::Some(context), - Option::None, - Option::None, - Option::None, - array![MintBatchRecipient { to: ALICE(), count: 1 }], - false, - false, - 0, - 0, - ); -} - // ================================================================================================ // ECOSYSTEM INTEGRATION (metagame assert_game_registered) // ================================================================================================ -/// Positive path: a self-bound lite deployment IS its own game. Its -/// `token_address()` returns itself and `game_registry_address()` is zero, so -/// the registry-less branch reduces to a trivially-true address equality. +/// Positive path: `assert_game_registered` now probes the token's SRC5 for +/// `IMINIGAME_TOKEN_LITE_ID` first (lite tokens expose no registry views). A +/// self-bound lite deployment IS its own game: `token_address()` returns +/// itself, the lite id matches, and the check reduces to a trivially-true +/// address equality. #[test] fn test_assert_game_registered_accepts_self_bound_lite_game() { let (token, _, _) = deploy_token_lite(); crate::metagame::metagame::assert_game_registered(token.contract_address); } -/// Negative path: a game whose `token_address()` points at some OTHER -/// registry-less token is not a valid pairing — self-binding means the only -/// accepted answer is the game's own address. A second LiteGameMock cannot -/// express this misconfiguration (it always returns itself), so the fake game -/// is a mocked address pointing at a real lite deployment. +/// Negative path: a game whose `token_address()` points at some OTHER lite +/// token is not a valid pairing — self-binding means the only accepted answer +/// is the game's own address. A second LiteGameMock cannot express this +/// misconfiguration (it always returns itself), so the fake game is a mocked +/// address pointing at a real lite deployment: the SRC5 probe finds the lite +/// 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_lite_token() { @@ -811,8 +540,6 @@ fn test_assert_game_registered_rejects_game_not_paired_with_lite_token() { let fake_game = addr('FAKE_GAME'); mock_call(fake_game, selector!("token_address"), token.contract_address, 1); - // token.game_registry_address() answers zero for real; the address - // equality fake_game == token then fails. crate::metagame::metagame::assert_game_registered(fake_game); } diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index 8595a418..3b05a85b 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -10,31 +10,27 @@ /// 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) and the -/// `game_address()` view — which returns the contract's own address — is the -/// honest advertisement of that to ecosystem consumers. +/// game/token mutual-pairing story is trivial (self == self), advertised to +/// ecosystem consumers via SRC5 (`IMINIGAME_TOKEN_LITE_ID`) rather than +/// through address-resolution views. /// -/// What is deliberately gone, and why it is safe to remove: -/// * **Registry** — one game: this contract. No `game_id_from_address` on -/// mint, no `game_address_from_id` anywhere, no stored game address at all. -/// * **Mutable token state** — no `game_over`/`completed_objective` latch. -/// The game contract is the sole authority; playability here is the -/// lifecycle window only, which lives packed inside the token id, so -/// `is_playable` costs zero storage reads. -/// * **`update_game` + metagame callbacks** — nothing to sync and nobody to -/// notify. `refresh_metadata` (ERC-4906) is the only post-action hook. -/// * **SRC5 round-trips** — the game is this contract; mint performs no -/// `supports_interface` calls. -/// * **Settings/objective validation on mint** — minters pass an -/// admin-configured `settings_id`; the game validates it at play time. +/// The external ABI is `IMinigameTokenLite`: 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 the lite 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` is the only per-token +/// storage (owner-renameable via `update_player_name`). /// /// Token ids use the lite-native 251-bit layout in `token_lite::packing` — /// NOT the full token's `token::structs::pack_token_id` layout (which stays -/// untouched, serving legacy denshokan). The lite layout drops the fields the -/// lite token never writes (game_id, objective_id, has_context, paymaster, -/// metadata), widens `settings_id` to 16 bits and `salt` to 16 bits, and -/// consolidates every spare bit into one component-owned, always-zero -/// reserved region in the high half. Indexers must branch their token-id +/// untouched, serving legacy denshokan). Indexers must branch their token-id /// decoder by contract generation. #[starknet::component] pub mod CoreTokenLiteComponent { @@ -47,11 +43,8 @@ pub mod CoreTokenLiteComponent { use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use starknet::{ - ContractAddress, get_block_timestamp, get_caller_address, get_contract_address, get_tx_info, - }; - use crate::token::interface::IMINIGAME_TOKEN_ID; - use crate::token::structs::{GameContextDetails, MintBatchRecipient, TokenMetadata}; + use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; + use crate::token::structs::{MintBatchRecipient, TokenMetadata}; use crate::token::token::{LifecycleTrait, token_state}; use crate::token::traits::OptionalMinter; use crate::token_lite::packing::{ @@ -101,30 +94,6 @@ pub mod CoreTokenLiteComponent { metadata.lifecycle.is_playable(get_block_timestamp()) } - fn assert_is_playable(self: @ComponentState, token_id: felt252) { - self.assert_lifecycle_open(token_id); - } - - fn assert_owner_and_playable( - self: @ComponentState, - token_id: felt252, - expected_owner: ContractAddress, - ) { - assert!(!expected_owner.is_zero(), "MinigameTokenLite: 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, - "MinigameTokenLite: Address is not owner of token {}", - token_id, - ); - self.assert_lifecycle_open(token_id); - } - fn settings_id(self: @ComponentState, token_id: felt252) -> u32 { unpack_settings_id(token_id) } @@ -150,57 +119,16 @@ pub mod CoreTokenLiteComponent { unpack_soulbound(token_id) } - fn game_address(self: @ComponentState) -> ContractAddress { - // The token IS the game contract (one-address architecture). Kept - // as a view so ecosystem consumers can still resolve the pairing. - get_contract_address() - } - - fn game_registry_address(self: @ComponentState) -> ContractAddress { - // Compat shim: MinigameComponent::initializer queries this before - // deciding whether to register with a registry. Zero = no registry. - Zero::zero() - } - fn mint( 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, to: ContractAddress, soulbound: bool, - paymaster: bool, salt: u16, - metadata: u16, ) -> felt252 { - // The signature matches IMinigameToken::mint so existing call - // sites work unchanged, but unsupported features must not be - // silently dropped — reject them loudly. - assert!(objective_id.is_none(), "MinigameTokenLite: objectives not supported"); - assert!(context.is_none(), "MinigameTokenLite: context not supported"); - assert!(client_url.is_none(), "MinigameTokenLite: client_url not supported"); - assert!( - renderer_address.is_none(), "MinigameTokenLite: per-token renderer not supported", - ); - assert!(skills_address.is_none(), "MinigameTokenLite: skills not supported"); - assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); - assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); - - // Single game — this contract. No SRC5 probe, no registry - // resolution. The parameter is kept for ABI parity and still - // catches caller misconfiguration (pointing at the wrong game). - assert!( - game_address == get_contract_address(), - "MinigameTokenLite: Game address does not match configured game", - ); - let caller = get_caller_address(); let current_time = get_block_timestamp(); @@ -233,7 +161,7 @@ pub mod CoreTokenLiteComponent { let mut contract_self = self.get_contract_mut(); let minted_by = MinterOpt::add_minter(ref contract_self, caller); - // settings_id keeps its Option ABI type for compat; the pack + // settings_id keeps its Option call-site type; the pack // asserts the value fits the lite layout's 16-bit field. Likewise // minted_by (u64 from OptionalMinter::add_minter) must fit 26 bits. let final_token_id = pack_lite_token_id( @@ -258,50 +186,26 @@ pub mod CoreTokenLiteComponent { final_token_id } - /// Batch mint identical tokens to one or more recipients with per-recipient - /// counts. ABI-compatible with `IMinigameToken::mint_batch_recipients` so - /// batch-minting metagames (tournaments, brackets) work unchanged against a - /// lite deployment; the same unsupported-parameter rules as `mint` apply. + /// 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 lite layout's 16-bit field). /// - /// Versus calling `mint` per token, the lifecycle math, tx-info read, game - /// check and minter registration are hoisted and paid once for the batch. + /// 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, - 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 { - assert!(objective_id.is_none(), "MinigameTokenLite: objectives not supported"); - assert!(context.is_none(), "MinigameTokenLite: context not supported"); - assert!(client_url.is_none(), "MinigameTokenLite: client_url not supported"); - assert!( - renderer_address.is_none(), "MinigameTokenLite: per-token renderer not supported", - ); - assert!(skills_address.is_none(), "MinigameTokenLite: skills not supported"); - assert!(!paymaster, "MinigameTokenLite: paymaster flag not supported"); - assert!(metadata == 0, "MinigameTokenLite: metadata field not supported"); - assert!( - game_address == get_contract_address(), - "MinigameTokenLite: Game address does not match configured game", - ); - let recipient_count = recipients.len(); assert!(recipient_count > 0, "MinigameTokenLite: recipients array cannot be empty"); @@ -401,17 +305,6 @@ pub mod CoreTokenLiteComponent { self.emit(MetadataUpdate { token_id: token_id.into() }); } - fn refresh_metadata_batch( - ref self: ComponentState, token_ids: Span, - ) { - assert!(token_ids.len() > 0, "MinigameTokenLite: token_ids array cannot be empty"); - let mut i: u32 = 0; - while i < token_ids.len() { - self.emit(MetadataUpdate { token_id: (*token_ids.at(i)).into() }); - i += 1; - } - } - fn update_player_name( ref self: ComponentState, token_id: felt252, name: felt252, ) { @@ -438,20 +331,38 @@ pub mod CoreTokenLiteComponent { +Drop, +ERC721Component::ERC721HooksTrait, > of InternalTrait { - /// Registers the SRC5 interface ids. There is no game argument — the - /// component is self-bound: the embedding contract is the game. + /// Registers the SRC5 interface id. There is no game argument — the + /// component is self-bound: the embedding contract is the game. Only + /// the lite id is registered; SRC5 is honest about the surface (a + /// lite token does NOT implement `IMinigameToken`). fn initializer(ref self: ComponentState) { let mut contract = self.get_contract_mut(); let mut src5_component = SRC5::get_component_mut(ref contract); src5_component.register_interface(IMINIGAME_TOKEN_LITE_ID); - // Also advertise the full-token id: ecosystem consumers (e.g. - // metagames) hard-assert it before wiring against a token. The - // lite token implements the subset of IMinigameToken that - // game-side components actually call (mint, assert_is_playable, - // player_name, refresh_metadata, game_registry_address); anything - // else reverts with ENTRYPOINT_NOT_FOUND rather than misbehaving - // silently. - src5_component.register_interface(IMINIGAME_TOKEN_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(), "MinigameTokenLite: 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, + "MinigameTokenLite: Address is not owner of token {}", + token_id, + ); + self.assert_lifecycle_open(token_id); } /// Lifecycle-window check only — there is deliberately no token-side diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index ae41891d..71608de7 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -179,6 +179,10 @@ Apply the same reasoning to future additive methods: extend the trait, leave the 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_LITE_ID`: it is derived +over `IMinigameTokenLite` minus `refresh_metadata` (the per-selector breakdown +is kept in the doc comment above the constant in `token/lite.cairo`). + ## Dependencies None - this is a leaf package with no internal dependencies. Uses only: diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo index a7ce03e1..50f1cc27 100644 --- a/packages/interfaces/src/token/lite.cairo +++ b/packages/interfaces/src/token/lite.cairo @@ -1,125 +1,102 @@ // Lite token interface — single-game, no mutable token state. // -// Gas-optimized subset of `IMinigameToken` 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: everything except `player_name` is unpacked from the token id -// itself, so every view is pure felt arithmetic plus at most one storage read. +// 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`: every other view is unpacked from the token id itself. // // Token ids use the lite-native 251-bit layout (see // `game_components_embeddable_game_standard::token_lite::packing`), NOT the -// full token's layout: it drops the fields the lite token never writes, widens -// settings_id and salt to 16 bits each, and consolidates all spare bits into -// one component-owned, always-zero reserved region in the high half from which -// future fields are carved. Indexers must branch their token-id decoder by +// full token's layout. Indexers must branch their token-id decoder by // contract generation. // -// `mint` keeps the exact `IMinigameToken::mint` signature (same selector, same -// calldata layout) so existing call sites and the `minigame::mint` helper work -// unchanged against a lite deployment. Parameters the lite token does not -// support (objective_id, context, client_url, renderer_address, skills_address, -// paymaster, metadata) must be passed as `None`/`false`/`0` — the -// implementation rejects anything else loudly rather than silently ignoring it. +// 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_LITE_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 +// (`CoreTokenLiteComponent::InternalTrait`); clients use `is_playable`. +// * `refresh_metadata_batch` — gone: a multicall of singles. +// * The full token's dead mint parameters (game_address, objective, context, +// client_url, renderer, skills, paymaster, metadata) are gone along with +// their reject-asserts. // // Semantics that differ from the full token: -// * `is_playable`/`assert_is_playable` check the lifecycle window only. There -// is no token-side `game_over`/`completed_objective` latch — ask the game. +// * `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. -// * There is no `update_game` — nothing to sync. `refresh_metadata` (ERC-4906 -// emit) is the only post-action hook a game needs. +// * 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, TokenMetadata}; /// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. /// -/// Surface is the trait below minus `refresh_metadata`/`refresh_metadata_batch`, -/// mirroring their exclusion from `IMINIGAME_TOKEN_ID`. Run `src5_rs parse` +/// Surface is the trait below minus `refresh_metadata`, mirroring the +/// refresh-function exclusion from `IMINIGAME_TOKEN_ID`. Run `src5_rs parse` /// against a stripped copy of this trait (see packages/interfaces/src/AGENTS.md) -/// to rederive. +/// to rederive: +/// mint: 0x301f9859704837f9b996bbffa025fe2570eeb0087cbdff42badfe51f5b26537 +/// mint_batch_recipients: 0x243cccd53204a3d22330ac6403c39ecba1cd11e5107832acf751829639bba2a +/// minted_by_address: 0x3c8691eac3f879268d352d7d5f6f28a456e3f92f4843fec780e3037d4f9d162 +/// player_name: 0x2cf33209d5df54b50609fc29863a6b916471ac903c3d15acbe89210cac085aa +/// update_player_name: 0x1f68f6ce969c632201a916c0ec4432e7edf5340a2b7a71172b820d22c2e9481 +/// token_metadata: 0x1ebdf5dc7aab5a2b9bd68eb3a453bfb8025371633679db9d0d918cf87f92dd0 +/// is_playable: 0x2fbc9e87d82f279727e61c9ebc25269905fd28fb8137aeead5f417ac4cc66de +/// settings_id: 0x2c1ab8f675f7da818ca288b9feb48811492444b5e6d822b3d1fe07728d1b714 +/// minted_by: 0x1017c8450696b88787feabb9b5f2584574556b2091690953c038e051d5801bb +/// is_soulbound: 0x38f66b071844d5c568a247092201c33b2ef3d3ac5bf07715050d15b213c48c2 pub const IMINIGAME_TOKEN_LITE_ID: felt252 = - 0x2dc0909ee1d6854df56adcced7d2cd9c3ce2f8d5aa788a754f0ffde901fd5e7; + 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37; #[starknet::interface] pub trait IMinigameTokenLite { 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); - /// Combined ownership + playability guard: one external call instead of - /// `owner_of` followed by `assert_is_playable`. `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: @TState, token_id: felt252, expected_owner: ContractAddress); 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; + /// 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 is_soulbound(self: @TState, token_id: felt252) -> bool; - /// Returns this contract's own address — the token IS the game contract - /// (self-binding is the only supported shape). Kept as a view so ecosystem - /// consumers can keep resolving the game ↔ token pairing generically. - fn game_address(self: @TState) -> ContractAddress; - /// Always returns the zero address — the lite token has no registry. Kept - /// so `MinigameComponent::initializer`, which unconditionally queries the - /// registry address before deciding whether to register the game, works - /// against a lite deployment without modification. - fn game_registry_address(self: @TState) -> ContractAddress; - /// Signature-compatible with `IMinigameToken::mint`. `game_address` must be - /// the token contract's own address (the game IS the token) — the parameter - /// survives for ABI parity and to catch caller misconfiguration; - /// `objective_id`, `context`, `client_url`, - /// `renderer_address`, `skills_address` must be `None`, `paymaster` must be - /// `false`, and `metadata` must be `0`. `settings_id` keeps its `Option` - /// ABI type for compat, but the value must fit the lite layout's 16-bit - /// field (`<= 0xFFFF`) or the mint reverts. + /// 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 + /// lite layout's 16-bit field (`<= 0xFFFF`) or the mint reverts. 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 with per-recipient counts. Signature-compatible with - /// `IMinigameToken::mint_batch_recipients`; the same unsupported-parameter - /// rules as `mint` apply, and salt is a single global counter across the - /// batch (`salt + sum(counts) - 1 <= 0xFFFF` — the lite layout's 16-bit - /// salt field). + /// Batch mint with per-recipient counts. Salt is a single global counter + /// across the batch (`salt + sum(counts) - 1 <= 0xFFFF` — the lite + /// layout's 16-bit salt field). 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; /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see /// `IMinigameToken::refresh_metadata` for the spam/existence trade-offs; /// identical semantics here. fn refresh_metadata(ref self: TState, token_id: felt252); - fn refresh_metadata_batch(ref self: TState, token_ids: Span); + /// Owner-gated rename; emits `MetadataUpdate`. fn update_player_name(ref self: TState, token_id: felt252, name: felt252); } diff --git a/packages/test_common/src/mocks/lite_game_mock.cairo b/packages/test_common/src/mocks/lite_game_mock.cairo index 4bc595f1..964df015 100644 --- a/packages/test_common/src/mocks/lite_game_mock.cairo +++ b/packages/test_common/src/mocks/lite_game_mock.cairo @@ -23,6 +23,11 @@ pub trait ILiteGameMock { 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] @@ -37,9 +42,11 @@ pub mod LiteGameMock { use game_components_embeddable_game_standard::minigame::interface::{ IMINIGAME_ID, IMinigame, IMinigameTokenData, }; - use game_components_embeddable_game_standard::minigame::minigame as minigame_libs; use game_components_embeddable_game_standard::minigame::structs::MintGameParams; use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; + use game_components_embeddable_game_standard::token_lite::interface::{ + IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, + }; use game_components_embeddable_game_standard::token_lite::packing::unpack_soulbound; use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; use openzeppelin_introspection::src5::SRC5Component; @@ -171,6 +178,10 @@ pub mod LiteGameMock { get_contract_address() } + /// `IMinigame::mint_game` keeps the full 15-arg trait shape; the lite + /// mint takes only the supported subset, so the parameters the lite + /// token dropped must be neutral — rejected here rather than silently + /// discarded. fn mint_game( self: @ContractState, player_name: Option, @@ -188,28 +199,46 @@ pub mod LiteGameMock { salt: u16, metadata: u16, ) -> felt252 { - minigame_libs::mint( - get_contract_address(), - get_contract_address(), - player_name, - settings_id, - start, - end, - objective_id, - context, - client_url, - renderer_address, - skills_address, - to, - soulbound, - paymaster, - salt, - metadata, - ) + assert!(objective_id.is_none(), "LiteGameMock: objectives not supported"); + assert!(context.is_none(), "LiteGameMock: context not supported"); + assert!(client_url.is_none(), "LiteGameMock: client_url not supported"); + assert!(renderer_address.is_none(), "LiteGameMock: renderer not supported"); + assert!(skills_address.is_none(), "LiteGameMock: skills not supported"); + assert!(!paymaster, "LiteGameMock: paymaster not supported"); + assert!(metadata == 0, "LiteGameMock: metadata not supported"); + let token = IMinigameTokenLiteDispatcher { contract_address: get_contract_address() }; + token.mint(player_name, settings_id, start, end, to, soulbound, salt) } fn mint_game_batch(self: @ContractState, mints: Array) -> Array { - minigame_libs::mint_batch(get_contract_address(), get_contract_address(), mints) + let token = IMinigameTokenLiteDispatcher { 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.objective_id.is_none(), "LiteGameMock: objectives not supported"); + assert!(m.context.is_none(), "LiteGameMock: context not supported"); + assert!(m.client_url.is_none(), "LiteGameMock: client_url not supported"); + assert!(m.renderer_address.is_none(), "LiteGameMock: renderer not supported"); + assert!(m.skills_address.is_none(), "LiteGameMock: skills not supported"); + assert!(!*m.paymaster, "LiteGameMock: paymaster not supported"); + assert!(*m.metadata == 0, "LiteGameMock: metadata not supported"); + token_ids + .append( + token + .mint( + *m.player_name, + *m.settings_id, + *m.start, + *m.end, + *m.to, + *m.soulbound, + *m.salt, + ), + ); + index += 1; + } + token_ids } } @@ -273,6 +302,14 @@ pub mod LiteGameMock { 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.core_token_lite.assert_owner_and_playable(token_id, expected_owner); + } + fn create_settings_difficulty( ref self: ContractState, name: ByteArray, description: ByteArray, difficulty: u8, ) { From d4aaac8d6addfcff3afac17f8262d3222134d9ee Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:50:03 -0700 Subject: [PATCH 12/33] =?UTF-8?q?feat(token=5Flite)!:=20restore=20objectiv?= =?UTF-8?q?e/context/client=5Furl/paymaster/metadata=20mint=20params=20?= =?UTF-8?q?=E2=80=94=20metadata=20widened=20to=2065=20bits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses part of the earlier ABI strip per user direction: the five mint parameters return with their ORIGINAL full-token behaviors, and the lite id's high half is now fully allocated (no reserved region). New high-u128 layout (low half unchanged): tx_hash(10) | salt(16) | paymaster(1) | has_context(1) | objective_id(30) | metadata(65). The spare bits were merged into the single writable metadata field, in line with the original layout's single-field design; a future protocol field would require a new contract generation (accepted trade-off). * mint / mint_batch_recipients: 12-arg shape (player_name, settings_id, start, end, objective_id, context, client_url, to|recipients, soulbound, paymaster, salt, metadata: u128). * objective_id: packed, INERT data the game interprets — no completion machinery; completed_objective stays always-false. objective_id view restored. * context: sets the has_context bit only; data NOT stored (full-token parity). Batch shares the bit. * client_url: storage-backed map + view, empty default; batch writes the url per token. * paymaster: packed bit. * metadata: u128 param packed into the 65-bit field; mint_metadata view. TokenMetadata.metadata (u16, deployed full-token ABI) stays 0 — never a truncation. * IMINIGAME_TOKEN_LITE_ID rederived over the 14-function surface (minus refresh_metadata): 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea * LiteGameMock forwards the standard 15-arg mint_game naturally now; only renderer/skills remain asserted-None (u16 metadata widens via .into()). * Tests: roundtrip/boundary/reject coverage for the restored fields, client_url storage + default, has_context without storage, batch sharing; bit-exact layout proof updated. 47 token_lite + 92 metagame green. Co-Authored-By: Claude Fable 5 --- docs/denshokan-lite-migration.md | 23 +- .../src/token_lite/AGENTS.md | 42 +-- .../src/token_lite/packing.cairo | 151 +++++++-- .../src/token_lite/tests/test_gas_bench.cairo | 10 +- .../token_lite/tests/test_token_lite.cairo | 299 ++++++++++++++++-- .../src/token_lite/token_lite_component.cairo | 72 ++++- packages/interfaces/src/token/lite.cairo | 67 +++- .../src/mocks/lite_game_mock.cairo | 50 ++- 8 files changed, 594 insertions(+), 120 deletions(-) diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index 6c784d76..353da7fc 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -96,22 +96,22 @@ The library-class pattern (already used by budokan's rewards class) dissolved th ## 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 dead fields (`game_id`, `objective_id`, `has_context`, `paymaster`, `metadata`) are gone from the id; `settings_id` and `salt` widen to 16 bits each; every remaining spare bit is consolidated into one component-owned reserved region. +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) | reserved(97, always zero)` +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–122 | reserved — component-owned, ALWAYS zero | -| 85–100 | settings_id (≤ 0xFFFF, ABI stays `Option`) | | | -| 101–126 | minted_by (26-bit minter id) | | | -| 127 | soulbound | | | +| 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 reserved region has no pack parameter and no public unpack accessor; future protocol- or game-facing fields are carved from it later, and because every lite id provably decodes it as 0, such carve-outs are non-breaking by construction. The batch salt bound rises to `salt + sum(counts) - 1 <= 0xFFFF`, and `settings_id > 0xFFFF` is now 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 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) @@ -119,15 +119,16 @@ With the layout compat shim retired, the ABI compat shim went with it — before | Change | Detail | |---|---| -| `mint` trimmed to 7 args | `mint(player_name, settings_id, start, end, to, soulbound, salt)` — the dead full-token params (`game_address`, `objective_id`, `context`, `client_url`, `renderer_address`, `skills_address`, `paymaster`, `metadata`) are gone **along with their reject-asserts**; `mint_batch_recipients` trims identically (recipients array in place of `to`) | +| `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`, `player_name` (near-zero-cost client/RPC conveniences) and `update_player_name` (owner-gated capability) stay with identical semantics | +| 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 = 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37` (rederived over the new surface minus `refresh_metadata`, per the refresh-exclusion convention) | +| 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 7-arg shape (drop the game-address argument and the eight `None`/`false`/`0` fillers), 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. +**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. --- diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md index b9370222..c2901f5b 100644 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ b/packages/embeddable_game_standard/src/token_lite/AGENTS.md @@ -16,9 +16,10 @@ two-phase init, a standalone preset, game-side call helpers). | 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 lite token by SRC5 (`IMINIGAME_TOKEN_LITE_ID`) | -| No mutable token state | No `update_game`, no metagame callbacks; `is_playable` = lifecycle window only, zero storage reads. `player_name` is the only per-token storage (owner-renameable) | +| 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 | | Token id layout is lite-native | `token_lite::packing::pack_lite_token_id` (251-bit) — its OWN layout, not the full token's (`token::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 `IMinigameToken`-compatible: the full token's dead mint params (game_address, objective, context, client_url, renderer, skills, paymaster, metadata) are gone along with their reject-asserts, 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 | +| Strip principle: machinery deleted, capability + read views kept | The ABI is NOT `IMinigameToken`-compatible: the full 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 full-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 — full-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) | | 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 (lite-native, 251 bits) @@ -40,23 +41,23 @@ Low u128 (128 bits): 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-122 | reserved | 97 | component-owned, ALWAYS packed as zero | +| 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 | -**Reserved-region ownership contract:** bits [26-122] of the high half belong -to the component. They are always packed as zero — there is no pack parameter -and no public unpack accessor. Future fields (protocol- or game-facing) are -carved from this region later; since every id minted under this layout -provably decodes the region as 0, any future field reads as 0 ("absent") on -all existing ids, making carve-outs non-breaking by construction. Do not stamp -data into these bits from outside the component. +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 (IMinigameTokenLite) -**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37` +**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea` (derived over the trait minus `refresh_metadata`, mirroring the refresh exclusion from `IMINIGAME_TOKEN_ID`) @@ -68,11 +69,11 @@ registry/game-address views. | Method | Cost | Notes | | --- | --- | --- | -| `mint(player_name, settings_id, start, end, to, soulbound, salt)` | 1 minter-map read (warm), optional name write, ERC721 mint | Trimmed 7-arg shape — no game address (self-bound), none of the full token's dead params | -| `mint_batch_recipients(player_name, settings_id, start, end, recipients, soulbound, salt)` | batch work hoisted; per token: pack + optional name write + ERC721 mint | Global salt counter over the lite 16-bit field (`salt + sum(counts) - 1 <= 0xFFFF`) | +| `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 lite 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` | 0 storage reads | Pure unpack of the token id — kept as client/RPC conveniences (also derivable from the documented id layout) | -| `player_name`, `minted_by_address` | 1 storage read | | +| `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 full token | | `update_player_name` | owner-gated write | Emits `MetadataUpdate` | @@ -87,7 +88,8 @@ capability and read views stay): * `refresh_metadata_batch` — a multicall of singles. Not present (reverts with ENTRYPOINT_NOT_FOUND): `update_game`, all batch -views, objectives/settings/context/renderer/skills/enumerable surfaces. +views, the objectives/settings/context creation and renderer/skills/enumerable +surfaces. ## Composition diff --git a/packages/embeddable_game_standard/src/token_lite/packing.cairo b/packages/embeddable_game_standard/src/token_lite/packing.cairo index 6a2f1944..7e8bc9ff 100644 --- a/packages/embeddable_game_standard/src/token_lite/packing.cairo +++ b/packages/embeddable_game_standard/src/token_lite/packing.cairo @@ -6,8 +6,8 @@ // boundary). This layout is OWNED by the lite token and is deliberately NOT the // full token's `token::structs::pack_token_id` layout — the full layout serves // legacy denshokan and keeps its bit positions untouched; the lite token drops -// the fields it never writes (game_id, objective_id, has_context, paymaster, -// metadata) and widens the ones it actually uses (settings_id, salt). +// the fields it never writes (game_id) and widens the ones it uses beyond the +// full token's widths (settings_id 16, salt 16, metadata 65). // Indexers must branch their decoder by contract generation. // // Low u128 (128 bits): @@ -25,17 +25,18 @@ // |-----------|------------------|----------|--------------------------------| // | 0-9 | tx_hash | 10 bits | last 10 bits of tx hash | // | 10-25 | salt | 16 bits | 65,536 tokens per tx (multicall)| -// | 26-122 | reserved | 97 bits | component-owned, ALWAYS zero | +// | 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) // -// RESERVED REGION CONTRACT: bits [26-122] of the high half are owned by the -// component and are ALWAYS packed as zero — there is no pack parameter and no -// public unpack accessor for them. Future fields (protocol- or game-facing) -// are carved from this region later; because every id minted under this layout -// provably decodes the region as 0, any future field decodes as 0 ("absent") -// on all existing ids, making such carve-outs non-breaking by construction. +// 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 @@ -53,8 +54,6 @@ use game_components_interfaces::structs::token::{Lifecycle, TokenMetadata}; pub use crate::token::structs::extract_tx_hash_bits; /// Data structure representing the lite packed token ID fields (for convenience). -/// The reserved region (high bits 26-122) is deliberately absent — it is -/// component-owned, always zero, and has no accessor. #[derive(Copy, Drop, Serde)] pub struct LitePackedTokenId { pub minted_at: u64, // 35 bits @@ -64,17 +63,23 @@ pub struct LitePackedTokenId { 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 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 (full-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 for DivRem-based unpacking. /// Each constant is a power of 2 matching a field width. /// DivRem extracts field (remainder) and shifts (quotient) in one operation. mod nz128 { + 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_25: NonZero = 0x2000000; pub const TWO_POW_26: NonZero = 0x4000000; + pub const TWO_POW_30: NonZero = 0x40000000; pub const TWO_POW_35: NonZero = 0x800000000; } @@ -83,7 +88,8 @@ mod nz128 { /// /// 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) | reserved(97, always zero) = 123 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_lite_token_id( minted_at: u64, @@ -94,6 +100,10 @@ pub fn pack_lite_token_id( 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, "LitePackedTokenId: minted_at exceeds 35-bit limit"); @@ -101,6 +111,8 @@ pub fn pack_lite_token_id( assert!(end_delay <= 0x1FFFFFF, "LitePackedTokenId: end_delay exceeds 25-bit limit"); assert!(settings_id <= 0xFFFF, "LitePackedTokenId: settings_id exceeds 16-bit limit"); assert!(minted_by <= 0x3FFFFFF, "LitePackedTokenId: minted_by exceeds 26-bit limit"); + assert!(objective_id <= 0x3FFFFFFF, "LitePackedTokenId: objective_id exceeds 30-bit limit"); + assert!(metadata <= 0x1FFFFFFFFFFFFFFFF, "LitePackedTokenId: metadata exceeds 65-bit limit"); // Low u128: minted_at(35) + start_delay(25) + end_delay(25) + settings_id(16) // + minted_by(26) + soulbound(1) = 128 bits @@ -117,18 +129,36 @@ pub fn pack_lite_token_id( + Into::::into(minted_by) * 0x20000000000000000000000000_u128 // shift 101 + soulbound_u128 * 0x80000000000000000000000000000000_u128; // shift 127 - // High u128: tx_hash(10) + salt(16) = 26 bits; bits 26-122 (reserved) are - // never written — always zero. salt is a u16 written into a 16-bit field, - // so unlike the full token's 10-bit salt it needs no mask. + // 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 full token's 10-bit salt it + // needs no mask. + let paymaster_u128: u128 = if paymaster { + 1 + } else { + 0 + }; + let has_context_u128: u128 = if has_context { + 1 + } else { + 0 + }; + let high: u128 = Into::::into(tx_hash & 0x3FF) - + Into::::into(salt) * 0x400_u128; // shift 10 + + Into::::into(salt) * 0x400_u128 // shift 10 + + paymaster_u128 * 0x4000000_u128 // shift 26 + + has_context_u128 * 0x8000000_u128 // shift 27 + + Into::::into(objective_id) * 0x10000000_u128 // shift 28 + + metadata * 0x400000000000000_u128; // shift 58 let packed = u256 { low, high }; packed.try_into().unwrap() } -/// Unpacks a lite token_id into its component fields using DivRem chains on each -/// u128 half. The reserved region (high quotient past salt) is discarded. +/// Unpacks a lite token_id into its component fields using DivRem chains on +/// each u128 half. metadata is the topmost high field, so it falls out as the +/// final quotient. #[inline(always)] pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { let packed: u256 = token_id.into(); @@ -143,9 +173,13 @@ pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { let (hi, settings_id) = DivRem::div_rem(hi, nz128::TWO_POW_16); let (soulbound_u128, minted_by) = DivRem::div_rem(hi, nz128::TWO_POW_26); - // Unpack high u128: tx_hash(10) | salt(16) | reserved(97, dropped) + // Unpack high u128: tx_hash(10) | salt(16) | paymaster(1) | has_context(1) + // | objective_id(30) | metadata(65, final quotient) let (hi, tx_hash) = DivRem::div_rem(high, nz128::TWO_POW_10); - let (_, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi, paymaster_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (hi, has_context_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (metadata, objective_id) = DivRem::div_rem(hi, nz128::TWO_POW_30); LitePackedTokenId { minted_at: minted_at.try_into().unwrap(), @@ -156,6 +190,10 @@ pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { soulbound: soulbound_u128 == 1, tx_hash: tx_hash.try_into().unwrap(), salt: salt.try_into().unwrap(), + paymaster: paymaster_u128 == 1, + has_context: has_context_u128 == 1, + objective_id: objective_id.try_into().unwrap(), + metadata, } } @@ -238,13 +276,68 @@ pub fn unpack_salt(token_id: felt252) -> u16 { salt.try_into().unwrap() } +/// Helper to unpack the paymaster flag from a lite token_id +#[inline(always)] +pub fn unpack_paymaster(token_id: felt252) -> bool { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (_, paymaster_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); + paymaster_u128 == 1 +} + +/// Helper to unpack the has_context flag from a lite token_id. The context +/// data itself is NOT stored on the token (full-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 (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (_, has_context_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); + has_context_u128 == 1 +} + +/// Helper to unpack objective_id from a lite token_id (inert data the game +/// interprets — the lite token has no completion machinery) +#[inline(always)] +pub fn unpack_objective_id(token_id: felt252) -> u32 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (_, objective_id) = DivRem::div_rem(hi, nz128::TWO_POW_30); + objective_id.try_into().unwrap() +} + +/// Helper to unpack the 65-bit metadata field from a lite token_id (inert +/// data the game interprets). Topmost high field — the final quotient. +#[inline(always)] +pub fn unpack_metadata(token_id: felt252) -> u128 { + let packed: u256 = token_id.into(); + let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); + let (metadata, _) = DivRem::div_rem(hi, nz128::TWO_POW_30); + metadata +} + /// Convert LitePackedTokenId to the shared TokenMetadata struct. /// -/// The lite token has no mutable state and never writes the full token's -/// extension fields, so `game_id`, `objective_id`, `has_context`, `paymaster`, -/// `metadata`, `game_over`, `completed_objective` and `completed_at` are all -/// zeroed. The lifecycle is reconstructed from minted_at + delays with the same -/// rule as the full token: end_delay == 0 means "no expiration" (end == 0). +/// The lite 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 full +/// 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 full token's ABI, which +/// cannot change), while the lite id packs 65 bits. Read the real value via +/// `IMinigameTokenLite::mint_metadata` / `unpack_metadata`. #[inline(always)] pub fn to_token_metadata(packed: LitePackedTokenId) -> TokenMetadata { TokenMetadata { @@ -264,9 +357,9 @@ pub fn to_token_metadata(packed: LitePackedTokenId) -> TokenMetadata { game_over: false, completed_objective: false, completed_at: 0, - has_context: false, - objective_id: 0, - paymaster: false, + has_context: packed.has_context, + objective_id: packed.objective_id, + paymaster: packed.paymaster, metadata: 0, } } diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo index 79bba74b..424052c2 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo @@ -137,17 +137,23 @@ fn setup_full() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddre } fn mint_lite(token: IMinigameTokenLiteDispatcher, _game: ContractAddress, salt: u16) -> felt252 { - // Trimmed 7-arg lite mint — no game address (self-bound), none of the - // full token's dead parameters. + // Lite mint — no game address (self-bound); the restored full-token + // params (objective/context/client_url/paymaster/metadata) neutral, to + // stay comparable with the full-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, ) } diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo index c8c989b5..7c017ecd 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo @@ -1,3 +1,4 @@ +use game_components_interfaces::structs::metagame::{GameContext, GameContextDetails}; use game_components_test_common::mocks::lite_game_mock::{ ILiteGameMockDispatcher, ILiteGameMockDispatcherTrait, }; @@ -18,8 +19,9 @@ use crate::token_lite::interface::{ IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, }; use crate::token_lite::packing::{ - unpack_end_delay, unpack_lite_token_id, unpack_minted_at, unpack_minted_by, unpack_salt, - unpack_settings_id, unpack_soulbound, unpack_start_delay, unpack_tx_hash, + unpack_end_delay, unpack_has_context, unpack_lite_token_id, unpack_metadata, unpack_minted_at, + unpack_minted_by, unpack_objective_id, unpack_paymaster, unpack_salt, unpack_settings_id, + unpack_soulbound, unpack_start_delay, unpack_tx_hash, }; use crate::token_lite::token_lite_component::CoreTokenLiteComponent; @@ -68,8 +70,9 @@ fn game_of(token: IMinigameTokenLiteDispatcher) -> ILiteGameMockDispatcher { ILiteGameMockDispatcher { contract_address: token.contract_address } } -/// Mint with the trimmed 7-arg shape — no game address (the token IS the -/// game), none of the full token's dead parameters. +/// 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: IMinigameTokenLiteDispatcher, player_name: Option, @@ -80,7 +83,30 @@ fn mint_basic( soulbound: bool, salt: u16, ) -> felt252 { - token.mint(player_name, settings_id, start, end, to, soulbound, salt) + 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(), + } } // ================================================================================================ @@ -131,11 +157,10 @@ fn test_mint_packs_expected_fields() { assert!(packed.minted_by == 1, "First minter should pack id 1"); assert!(packed.salt == 7, "salt mismatch"); - // Reserved region (high bits 26-122) is component-owned and must be - // provably zero on every minted id: only tx_hash(10) + salt(16) occupy - // the high half. + // 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, "reserved bits must be zero"); // 2^26 + 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"); @@ -167,6 +192,14 @@ fn test_mint_defaults_and_metadata_view() { 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"); } @@ -434,9 +467,14 @@ fn batch_neutral( Option::Some(5), Option::None, Option::None, + Option::None, + Option::None, + Option::None, recipients, false, + false, salt, + 0, ) } @@ -568,6 +606,10 @@ fn test_helper_unpackers_agree_with_full_unpack() { 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"); } @@ -575,8 +617,9 @@ fn test_helper_unpackers_agree_with_full_unpack() { /// Bit-exact layout proof: with every input pinned (including the tx hash), /// the minted id must equal the arithmetic reconstruction of the documented /// lite layout — low: minted_at | start_delay<<35 | end_delay<<60 | -/// settings_id<<85 | minted_by<<101 | soulbound<<127; high: tx_hash | salt<<10; -/// reserved bits [26-122] of the high half all zero. +/// 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_lite_layout_bit_positions_exact() { let (token, _, _) = deploy_token_lite(); @@ -584,27 +627,38 @@ fn test_lite_layout_bit_positions_exact() { start_cheat_transaction_hash(token.contract_address, 0x123456789abcdef); cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); - let token_id = mint_basic( - token, - Option::None, - Option::Some(0xABCD), - Option::Some(2000), - Option::Some(5000), - ALICE(), - true, - 0x1234, - ); + 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. + // 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; // tx_hash | salt << 10 + 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, "lite layout bit positions must match the documented table"); } @@ -630,3 +684,202 @@ fn test_mint_rejects_settings_id_over_16_bits() { 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: IMinigameTokenLiteDispatcher, + 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_lite(); + 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_lite_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 lite 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_lite(); + 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: "LitePackedTokenId: objective_id exceeds 30-bit limit")] +fn test_mint_rejects_objective_id_over_30_bits() { + let (token, _, _) = deploy_token_lite(); + 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: "LitePackedTokenId: metadata exceeds 65-bit limit")] +fn test_mint_rejects_metadata_over_65_bits() { + let (token, _, _) = deploy_token_lite(); + 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 full 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_lite(); + 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 +/// (full-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_lite(); + 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_lite(); + 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; + } +} diff --git a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo index 3b05a85b..b180bd98 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo @@ -2,8 +2,8 @@ /// /// Single-game, storage-minimal variant of `CoreTokenComponent`, built for /// deployments (e.g. death-mountain-style dungeons) that never used the -/// multi-game registry, objectives, context, skills, per-token renderers or -/// client urls, and that keep game-over / objective completion authority in +/// multi-game registry, objective-completion machinery, skills or per-token +/// renderers, and that keep game-over / objective completion authority in /// the game contract itself. /// /// **Self-binding only (one-address architecture):** this component is @@ -25,8 +25,15 @@ /// * **`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` is the only per-token -/// storage (owner-renameable via `update_player_name`). +/// 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 full-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 — full-token parity); `client_url` is +/// storage-backed with a `client_url` view. /// /// Token ids use the lite-native 251-bit layout in `token_lite::packing` — /// NOT the full token's `token::structs::pack_token_id` layout (which stays @@ -35,6 +42,7 @@ #[starknet::component] pub mod CoreTokenLiteComponent { use core::num::traits::Zero; + use game_components_interfaces::structs::metagame::GameContextDetails; use game_components_interfaces::token::lite::{IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite}; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; @@ -49,12 +57,14 @@ pub mod CoreTokenLiteComponent { use crate::token::traits::OptionalMinter; use crate::token_lite::packing::{ extract_tx_hash_bits, pack_lite_token_id, to_token_metadata, unpack_lite_token_id, - unpack_minted_by, unpack_settings_id, unpack_soulbound, + unpack_metadata, unpack_minted_by, unpack_objective_id, unpack_settings_id, + unpack_soulbound, }; #[storage] pub struct Storage { token_player_names: Map, + token_client_url: Map, } #[event] @@ -86,6 +96,8 @@ pub mod CoreTokenLiteComponent { // 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 lite id + // packs 65 bits — read them via `mint_metadata`. to_token_metadata(unpack_lite_token_id(token_id)) } @@ -119,15 +131,32 @@ pub mod CoreTokenLiteComponent { 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(); @@ -163,7 +192,10 @@ pub mod CoreTokenLiteComponent { // settings_id keeps its Option call-site type; the pack // asserts the value fits the lite layout's 16-bit field. Likewise - // minted_by (u64 from OptionalMinter::add_minter) must fit 26 bits. + // minted_by (u64 from OptionalMinter::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 + // (full-token parity: its context hook was a documented no-op). let final_token_id = pack_lite_token_id( current_time, start_delay, @@ -173,11 +205,18 @@ pub mod CoreTokenLiteComponent { 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); @@ -202,9 +241,14 @@ pub mod CoreTokenLiteComponent { 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, "MinigameTokenLite: recipients array cannot be empty"); @@ -254,8 +298,12 @@ pub mod CoreTokenLiteComponent { let mut contract_self = self.get_contract_mut(); let minted_by = MinterOpt::add_minter(ref contract_self, 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 (full-token parity). + let has_context = context.is_some(); - // Per-token work: pack, optional name write, ERC721 mint. + // 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; @@ -275,11 +323,21 @@ pub mod CoreTokenLiteComponent { 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); diff --git a/packages/interfaces/src/token/lite.cairo b/packages/interfaces/src/token/lite.cairo index 50f1cc27..c528c244 100644 --- a/packages/interfaces/src/token/lite.cairo +++ b/packages/interfaces/src/token/lite.cairo @@ -4,7 +4,8 @@ // 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`: every other view is unpacked from the token id itself. +// `player_name` and `client_url`: every other view is unpacked from the token +// id itself. // // Token ids use the lite-native 251-bit layout (see // `game_components_embeddable_game_standard::token_lite::packing`), NOT the @@ -20,18 +21,32 @@ // the embedding game's own guards, internal calls now // (`CoreTokenLiteComponent::InternalTrait`); clients use `is_playable`. // * `refresh_metadata_batch` — gone: a multicall of singles. -// * The full token's dead mint parameters (game_address, objective, context, -// client_url, renderer, skills, paymaster, metadata) are gone along with -// their reject-asserts. +// * The full 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 full-token behaviors: +// * `objective_id` — packed into the id as inert data the game interprets; +// the lite 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 (full-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 full token's u16 to a u128 holding a +// 65-bit packed field; read via `mint_metadata(token_id)`. // // Semantics that differ from the full 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. +// 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, TokenMetadata}; /// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. @@ -40,18 +55,21 @@ use crate::structs::token::{MintBatchRecipient, TokenMetadata}; /// refresh-function exclusion from `IMINIGAME_TOKEN_ID`. Run `src5_rs parse` /// against a stripped copy of this trait (see packages/interfaces/src/AGENTS.md) /// to rederive: -/// mint: 0x301f9859704837f9b996bbffa025fe2570eeb0087cbdff42badfe51f5b26537 -/// mint_batch_recipients: 0x243cccd53204a3d22330ac6403c39ecba1cd11e5107832acf751829639bba2a -/// minted_by_address: 0x3c8691eac3f879268d352d7d5f6f28a456e3f92f4843fec780e3037d4f9d162 -/// player_name: 0x2cf33209d5df54b50609fc29863a6b916471ac903c3d15acbe89210cac085aa -/// update_player_name: 0x1f68f6ce969c632201a916c0ec4432e7edf5340a2b7a71172b820d22c2e9481 /// 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_LITE_ID: felt252 = - 0x2ec4714e0b5610e5cffd262be7c69b721a6865f9a8ce7e1094c8211f3beaa37; + 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea; #[starknet::interface] pub trait IMinigameTokenLite { @@ -65,33 +83,56 @@ pub trait IMinigameTokenLite { /// the one view a packing-aware caller cannot derive from the id alone. fn minted_by_address(self: @TState, token_id: felt252) -> ContractAddress; fn is_soulbound(self: @TState, token_id: felt252) -> bool; + /// Packed objective id — inert data the game interprets; the lite 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; + /// 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 - /// lite layout's 16-bit field (`<= 0xFFFF`) or the mint reverts. + /// lite 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, 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; /// Batch mint with per-recipient counts. Salt is a single global counter /// across the batch (`salt + sum(counts) - 1 <= 0xFFFF` — the lite - /// layout's 16-bit salt field). + /// 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, 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; /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see /// `IMinigameToken::refresh_metadata` for the spam/existence trade-offs; diff --git a/packages/test_common/src/mocks/lite_game_mock.cairo b/packages/test_common/src/mocks/lite_game_mock.cairo index 964df015..c48f00b5 100644 --- a/packages/test_common/src/mocks/lite_game_mock.cairo +++ b/packages/test_common/src/mocks/lite_game_mock.cairo @@ -178,10 +178,13 @@ pub mod LiteGameMock { get_contract_address() } - /// `IMinigame::mint_game` keeps the full 15-arg trait shape; the lite - /// mint takes only the supported subset, so the parameters the lite - /// token dropped must be neutral — rejected here rather than silently - /// discarded. + /// `IMinigame::mint_game` keeps the full 15-arg trait shape. The lite + /// mint now carries objective/context/client_url/paymaster/metadata + /// with their original full-token behaviors, so those forward + /// naturally (the standard trait's u16 metadata widens into the lite + /// u128 field via `.into()`); only renderer/skills — which the lite + /// token has no surface for — must be neutral, rejected here rather + /// than silently discarded. fn mint_game( self: @ContractState, player_name: Option, @@ -199,15 +202,24 @@ pub mod LiteGameMock { salt: u16, metadata: u16, ) -> felt252 { - assert!(objective_id.is_none(), "LiteGameMock: objectives not supported"); - assert!(context.is_none(), "LiteGameMock: context not supported"); - assert!(client_url.is_none(), "LiteGameMock: client_url not supported"); assert!(renderer_address.is_none(), "LiteGameMock: renderer not supported"); assert!(skills_address.is_none(), "LiteGameMock: skills not supported"); - assert!(!paymaster, "LiteGameMock: paymaster not supported"); - assert!(metadata == 0, "LiteGameMock: metadata not supported"); let token = IMinigameTokenLiteDispatcher { contract_address: get_contract_address() }; - token.mint(player_name, settings_id, start, end, to, soulbound, salt) + 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 { @@ -216,13 +228,16 @@ pub mod LiteGameMock { let mut index: u32 = 0; while index < mints.len() { let m = mints.at(index); - assert!(m.objective_id.is_none(), "LiteGameMock: objectives not supported"); - assert!(m.context.is_none(), "LiteGameMock: context not supported"); - assert!(m.client_url.is_none(), "LiteGameMock: client_url not supported"); assert!(m.renderer_address.is_none(), "LiteGameMock: renderer not supported"); assert!(m.skills_address.is_none(), "LiteGameMock: skills not supported"); - assert!(!*m.paymaster, "LiteGameMock: paymaster not supported"); - assert!(*m.metadata == 0, "LiteGameMock: metadata 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 @@ -231,9 +246,14 @@ pub mod LiteGameMock { *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; From 3feb2eb6726cb832ffb622fcaf81af3c6ee0d7fe Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:15:47 -0700 Subject: [PATCH 13/33] =?UTF-8?q?refactor!:=20the=20lite=20token=20is=20th?= =?UTF-8?q?e=20standard=20=E2=80=94=20rename=20Lite=E2=86=92(none),=20orig?= =?UTF-8?q?inal=20token=E2=86=92legacy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-level rename only: every interface-id VALUE and selector is identical — deployed contracts registered them on-chain and the values are frozen. Naming map (old → new / legacy): - IMinigameTokenLite(*Dispatcher*) → IMinigameToken(*Dispatcher*) - IMINIGAME_TOKEN_LITE_ID → IMINIGAME_TOKEN_ID (value 0x15951d…7aea unchanged) - IMinigameToken (original full trait) → IMinigameTokenLegacy - IMINIGAME_TOKEN_ID (original) → IMINIGAME_TOKEN_LEGACY_ID (value 0x246f61…9906 unchanged) - interfaces: token/lite.cairo → token/core.cairo (new standard); original core trait moved to token/legacy.cairo - embeddable_game_standard: src/token_lite → src/token; src/token → src/token_legacy (everything inside token_legacy keeps its names — CoreTokenComponent, structs::PackedTokenId, … — the path is the marker) - CoreTokenLiteComponent → MinigameTokenComponent (CoreTokenLiteImpl → MinigameTokenImpl) - token::packing: LitePackedTokenId → PackedTokenId, pack_lite_token_id → pack_token_id, unpack_lite_token_id → unpack_token_id - error strings: "MinigameTokenLite: …" → "MinigameToken: …", "LitePackedTokenId: …" → "PackedTokenId: …" - test_common: lite_game_mock/LiteGameMock → standard_game_mock/StandardGameMock The minter is standard, not optional — absorbed into MinigameTokenComponent with storage-name compatibility (minter_counter, minter_addresses, minter_id_by_address), the same IMinigameTokenMinter surface + id and the same MinterRegistryUpdate event; OptionalMinter remains only in token_legacy. The standard token's SRC5 id is NOT rederived (its trait did not change). Consumers of the original token (minigame/metagame/registry libs and components, renderer utilities, examples) now target the *Legacy* names and token_legacy:: paths. metagame::assert_game_registered probes IMINIGAME_TOKEN_ID (the standard) first; the legacy registry fallback is unchanged. CI matrices: module token → token_legacy (ubuntu-latest-32), token_lite → token (ubuntu-latest-8); codecov build count unchanged (18). Co-Authored-By: Claude Fable 5 --- .github/workflows/main-ci.yml | 4 +- .github/workflows/pr-ci.yml | 4 +- AGENTS.md | 9 +- docs/denshokan-lite-migration.md | 37 +++ packages/embeddable_game_standard/Scarb.toml | 2 +- .../embeddable_game_standard/src/lib.cairo | 2 +- .../src/metagame/AGENTS.md | 2 +- .../src/metagame/README.md | 2 +- .../src/metagame/metagame.cairo | 24 +- .../src/metagame/metagame_component.cairo | 14 +- .../tests/test_fuzz_mint_parameters.cairo | 18 +- .../src/metagame/tests/test_libs.cairo | 46 ++-- .../tests/test_metagame_component.cairo | 12 +- .../metagame/tests/test_tournament_flow.cairo | 16 +- .../minigame/extensions/objectives/libs.cairo | 6 +- .../minigame/extensions/settings/libs.cairo | 12 +- .../src/minigame/minigame.cairo | 14 +- .../src/minigame/minigame_component.cairo | 14 +- .../tests/test_minigame_component.cairo | 4 +- .../embeddable_game_standard/src/token.cairo | 11 +- .../src/token/AGENTS.md | 218 +++++++++------- .../src/token/interface.cairo | 234 +---------------- .../minigame_token_component.cairo} | 195 ++++++++++----- .../src/{token_lite => token}/packing.cairo | 90 +++---- .../src/token/tests.cairo | 38 +-- .../tests/test_gas_bench.cairo | 104 ++++---- .../tests/test_token.cairo} | 219 ++++++++-------- .../src/token_legacy.cairo | 10 + .../src/token_legacy/AGENTS.md | 98 ++++++++ .../src/{token => token_legacy}/CLAUDE.md | 0 .../src/{token => token_legacy}/GEMINI.md | 0 .../src/{token => token_legacy}/README.md | 0 .../{token => token_legacy}/extensions.cairo | 0 .../extensions/context.cairo | 0 .../extensions/context/context.cairo | 4 +- .../extensions/context/interface.cairo | 0 .../extensions/enumerable.cairo | 0 .../extensions/enumerable/enumerable.cairo | 2 +- .../extensions/enumerable/interface.cairo | 0 .../extensions/minter.cairo | 0 .../extensions/minter/interface.cairo | 0 .../extensions/minter/minter.cairo | 4 +- .../extensions/objectives.cairo | 0 .../extensions/objectives/interface.cairo | 0 .../extensions/objectives/objectives.cairo | 12 +- .../extensions/renderer.cairo | 0 .../extensions/renderer/interface.cairo | 0 .../extensions/renderer/renderer.cairo | 10 +- .../extensions/settings.cairo | 0 .../extensions/settings/interface.cairo | 0 .../extensions/settings/settings.cairo | 12 +- .../extensions/skills.cairo | 0 .../extensions/skills/interface.cairo | 0 .../extensions/skills/skills.cairo | 10 +- .../src/token_legacy/interface.cairo | 235 ++++++++++++++++++ .../{token => token_legacy}/noop_traits.cairo | 2 +- .../src/{token => token_legacy}/structs.cairo | 0 .../src/token_legacy/tests.cairo | 33 +++ .../tests/examples.cairo | 0 .../tests/examples/full_token_contract.cairo | 16 +- .../examples/minigame_registry_contract.cairo | 6 +- .../examples/minimal_optimized_example.cairo | 6 +- .../examples/single_game_token_contract.cairo | 16 +- .../{token => token_legacy}/tests/libs.cairo | 0 .../{token => token_legacy}/tests/mocks.cairo | 0 .../{token => token_legacy}/tests/setup.cairo | 2 +- .../tests/test_additional_coverage.cairo | 2 +- .../tests/test_address_utils.cairo | 2 +- .../tests/test_batch_views.cairo | 4 +- .../tests/test_component_coverage.cairo | 8 +- .../tests/test_context.cairo | 4 +- .../tests/test_context_coverage.cairo | 2 +- .../tests/test_core_token.cairo | 10 +- .../tests/test_core_token_coverage.cairo | 4 +- .../tests/test_enumerable.cairo | 4 +- .../tests/test_events.cairo | 6 +- .../tests/test_examples_coverage.cairo | 2 +- .../tests/test_extensions.cairo | 4 +- .../tests/test_full_token_contract.cairo | 2 +- .../tests/test_fuzz.cairo | 2 +- .../tests/test_integration.cairo | 2 +- .../tests/test_lifecycle.cairo | 4 +- .../tests/test_minimal_optimized.cairo | 6 +- .../tests/test_minter.cairo | 6 +- .../tests/test_noop_traits.cairo | 4 +- .../tests/test_objectives.cairo | 4 +- .../tests/test_packed_token_id.cairo | 2 +- .../tests/test_renderer.cairo | 6 +- .../tests/test_settings.cairo | 4 +- .../tests/test_skills.cairo | 6 +- .../tests/test_structs_coverage.cairo | 2 +- .../tests/test_token_state.cairo | 6 +- .../src/{token => token_legacy}/token.cairo | 0 .../token/address_utils.cairo | 0 .../token/lifecycle.cairo | 2 +- .../token/token_state.cairo | 4 +- .../token_component.cairo | 20 +- .../src/{token => token_legacy}/traits.cairo | 0 .../src/token_lite.cairo | 9 - .../src/token_lite/AGENTS.md | 121 --------- .../src/token_lite/interface.cairo | 5 - .../src/token_lite/tests.cairo | 7 - packages/interfaces/src/AGENTS.md | 40 +-- packages/interfaces/src/README.md | 4 +- packages/interfaces/src/lib.cairo | 10 +- packages/interfaces/src/token.cairo | 8 +- packages/interfaces/src/token/core.cairo | 155 +++++++----- packages/interfaces/src/token/legacy.cairo | 116 +++++++++ packages/interfaces/src/token/lite.cairo | 143 ----------- .../tests/test_leaderboard_pure.cairo | 2 +- .../tests/test_ticket_booth.cairo | 6 +- packages/test_common/src/AGENTS.md | 2 +- .../src/examples/full_token_contract.cairo | 16 +- .../examples/minigame_registry_contract.cairo | 4 +- .../examples/minimal_optimized_example.cairo | 6 +- .../examples/single_game_token_contract.cairo | 16 +- packages/test_common/src/mocks.cairo | 2 +- .../src/mocks/mock_enumerable.cairo | 2 +- ...me_mock.cairo => standard_game_mock.cairo} | 97 ++++---- .../utilities/src/renderer/metadata.cairo | 2 +- packages/utilities/src/renderer/svg.cairo | 4 +- .../src/renderer/tests/test_renderer.cairo | 2 +- 122 files changed, 1455 insertions(+), 1287 deletions(-) rename packages/embeddable_game_standard/src/{token_lite/token_lite_component.cairo => token/minigame_token_component.cairo} (68%) rename packages/embeddable_game_standard/src/{token_lite => token}/packing.cairo (81%) rename packages/embeddable_game_standard/src/{token_lite => token}/tests/test_gas_bench.cairo (73%) rename packages/embeddable_game_standard/src/{token_lite/tests/test_token_lite.cairo => token/tests/test_token.cairo} (83%) create mode 100644 packages/embeddable_game_standard/src/token_legacy.cairo create mode 100644 packages/embeddable_game_standard/src/token_legacy/AGENTS.md rename packages/embeddable_game_standard/src/{token => token_legacy}/CLAUDE.md (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/GEMINI.md (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/README.md (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/context.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/context/context.cairo (89%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/context/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/enumerable.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/enumerable/enumerable.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/enumerable/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/minter.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/minter/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/minter/minter.cairo (96%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/objectives.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/objectives/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/objectives/objectives.cairo (95%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/renderer.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/renderer/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/renderer/renderer.cairo (93%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/settings.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/settings/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/settings/settings.cairo (95%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/skills.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/skills/interface.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/extensions/skills/skills.cairo (93%) create mode 100644 packages/embeddable_game_standard/src/token_legacy/interface.cairo rename packages/embeddable_game_standard/src/{token => token_legacy}/noop_traits.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/structs.cairo (100%) create mode 100644 packages/embeddable_game_standard/src/token_legacy/tests.cairo rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/examples.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/examples/full_token_contract.cairo (97%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/examples/minigame_registry_contract.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/examples/minimal_optimized_example.cairo (96%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/examples/single_game_token_contract.cairo (96%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/libs.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/mocks.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/setup.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_additional_coverage.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_address_utils.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_batch_views.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_component_coverage.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_context.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_context_coverage.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_core_token.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_core_token_coverage.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_enumerable.cairo (97%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_events.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_examples_coverage.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_extensions.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_full_token_contract.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_fuzz.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_integration.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_lifecycle.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_minimal_optimized.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_minter.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_noop_traits.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_objectives.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_packed_token_id.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_renderer.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_settings.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_skills.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_structs_coverage.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/tests/test_token_state.cairo (99%) rename packages/embeddable_game_standard/src/{token => token_legacy}/token.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/token/address_utils.cairo (100%) rename packages/embeddable_game_standard/src/{token => token_legacy}/token/lifecycle.cairo (97%) rename packages/embeddable_game_standard/src/{token => token_legacy}/token/token_state.cairo (97%) rename packages/embeddable_game_standard/src/{token => token_legacy}/token_component.cairo (98%) rename packages/embeddable_game_standard/src/{token => token_legacy}/traits.cairo (100%) delete mode 100644 packages/embeddable_game_standard/src/token_lite.cairo delete mode 100644 packages/embeddable_game_standard/src/token_lite/AGENTS.md delete mode 100644 packages/embeddable_game_standard/src/token_lite/interface.cairo delete mode 100644 packages/embeddable_game_standard/src/token_lite/tests.cairo create mode 100644 packages/interfaces/src/token/legacy.cairo delete mode 100644 packages/interfaces/src/token/lite.cairo rename packages/test_common/src/mocks/{lite_game_mock.cairo => standard_game_mock.cairo} (78%) diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index 4138d8d9..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 @@ -179,7 +179,7 @@ jobs: runner: ubuntu-latest-8 fuzzer_runs: 32 - package: game_components_embeddable_game_standard - module: token_lite + module: token runner: ubuntu-latest-8 fuzzer_runs: 32 # Metagame diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index f9f3165a..0b253f1e 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -231,11 +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_lite 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 66eae40f..58d53650 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 @@ -123,7 +124,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 @@ -135,11 +136,11 @@ When adding a new module to a group package, update **both** files: | Group Package | Module | Runner | Fuzzer Runs | |---------------|--------|--------|-------------| -| `embeddable_game_standard` | `token` | `ubuntu-latest-32` | 32 | +| `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_lite` | `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 | diff --git a/docs/denshokan-lite-migration.md b/docs/denshokan-lite-migration.md index 353da7fc..84782fc7 100644 --- a/docs/denshokan-lite-migration.md +++ b/docs/denshokan-lite-migration.md @@ -170,3 +170,40 @@ With the layout compat shim retired, the ABI compat shim went with it — before 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/embeddable_game_standard/Scarb.toml b/packages/embeddable_game_standard/Scarb.toml index c59fcda8..4e697588 100644 --- a/packages/embeddable_game_standard/Scarb.toml +++ b/packages/embeddable_game_standard/Scarb.toml @@ -28,5 +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::lite_game_mock::LiteGameMock", + "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 26b52bde..382284f4 100644 --- a/packages/embeddable_game_standard/src/lib.cairo +++ b/packages/embeddable_game_standard/src/lib.cairo @@ -2,4 +2,4 @@ pub mod metagame; pub mod minigame; pub mod registry; pub mod token; -pub mod token_lite; +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..5779b991 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -89,7 +89,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/README.md b/packages/embeddable_game_standard/src/metagame/README.md index 63cffc9a..ba30479c 100644 --- a/packages/embeddable_game_standard/src/metagame/README.md +++ b/packages/embeddable_game_standard/src/metagame/README.md @@ -108,7 +108,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/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 60a582c0..35354b8a 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -6,10 +6,10 @@ use game_components_embeddable_game_standard::minigame::interface::{ use game_components_embeddable_game_standard::registry::interface::{ FEE_DENOMINATOR, GameFeeInfo, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, }; -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::token::lite::IMINIGAME_TOKEN_LITE_ID; +use game_components_interfaces::token::core::IMINIGAME_TOKEN_ID; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::metagame::structs::MintMetagameParams; @@ -17,12 +17,12 @@ 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_LITE_ID` is a self-bound lite token (the game contract IS -/// the token — lite tokens expose no registry/game-address views), so +/// `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 full +/// `token_address()` must be the game itself. Otherwise the token is a legacy /// token: registry-backed (multi-game) tokens ask the registry, and a zero -/// `game_registry_address()` (single-game full token) again means the mutual +/// `game_registry_address()` (single-game legacy token) again means the mutual /// pairing is the check. /// /// # Arguments @@ -31,11 +31,11 @@ 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 token_src5_dispatcher = ISRC5Dispatcher { contract_address: minigame_token_address }; - if token_src5_dispatcher.supports_interface(IMINIGAME_TOKEN_LITE_ID) { + if token_src5_dispatcher.supports_interface(IMINIGAME_TOKEN_ID) { assert!(minigame_token_address == game_address, "Game is not registered"); return; } - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); @@ -93,7 +93,7 @@ pub fn mint( 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 { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; minigame_token_dispatcher @@ -118,7 +118,7 @@ pub fn mint( // If no game address is provided, mint a token through the default token contract (blank // game) Option::None => { - let minigame_token_dispatcher = IMinigameTokenDispatcher { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: default_token_address, }; minigame_token_dispatcher @@ -215,7 +215,7 @@ pub fn calculate_game_fee(revenue: u128, fee_numerator: u16) -> u128 { 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 { + let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 8fb0d9d0..f17dbd02 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -6,7 +6,7 @@ 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_embeddable_game_standard::token_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; @@ -21,7 +21,9 @@ pub mod MetagameComponent { use crate::metagame::structs::MintMetagameParams; use crate::minigame::interface::{IMinigameDispatcher, IMinigameDispatcherTrait}; use crate::registry::interface::{IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait}; - use crate::token::interface::{IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait}; + use crate::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, + }; #[storage] pub struct Storage { @@ -75,8 +77,8 @@ pub mod MetagameComponent { 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", + minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_LEGACY_ID), + "Metagame: Default token contract does not support IMinigameTokenLegacy", ); self.default_token_address.write(default_token_address); } @@ -153,7 +155,9 @@ pub mod MetagameComponent { // 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 token_dispatcher = IMinigameTokenLegacyDispatcher { + contract_address: token_address, + }; let registry_address = token_dispatcher.game_registry_address(); let registry_dispatcher = IMinigameRegistryDispatcher { contract_address: registry_address, 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..393f5626 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; @@ -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![ @@ -476,10 +476,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 +507,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 +825,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 ce1682c8..6ac229e6 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}; @@ -110,7 +110,7 @@ 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"); } @@ -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"); @@ -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"); } @@ -373,7 +373,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"); } @@ -406,7 +406,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"); @@ -442,7 +442,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"); } @@ -604,7 +604,7 @@ fn test_mint_batch_preserves_order() { let token_ids = libs::mint_batch(token_address, 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)); @@ -707,7 +707,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"); } @@ -777,10 +777,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 +807,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, @@ -1105,7 +1105,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,7 +1380,7 @@ 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 lite token: the SRC5 probe for IMINIGAME_TOKEN_LITE_ID answers + // 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); @@ -1398,7 +1398,7 @@ 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 lite token — falls through to the registry path. + // 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); @@ -1457,7 +1457,7 @@ fn test_mint_batch_mixed_game_addresses() { assert!(token_ids.len() == 2, "Should return 2 token IDs"); - let token_dispatcher = IMinigameTokenDispatcher { contract_address: token_address }; + let token_dispatcher = IMinigameTokenLegacyDispatcher { 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"); @@ -1629,10 +1629,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, }; @@ -1660,7 +1660,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, @@ -1937,7 +1937,7 @@ 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 } } 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..16f75bc2 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 @@ -595,10 +595,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 +628,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 +950,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 +968,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" 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..1bb4436e 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, @@ -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 }; @@ -422,10 +422,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 +454,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 +772,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 9c4a8e76..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,4 +1,4 @@ -use game_components_embeddable_game_standard::token::extensions::objectives::interface::{ +use game_components_embeddable_game_standard::token_legacy::extensions::objectives::interface::{ IMINIGAME_TOKEN_OBJECTIVES_ID, IMinigameTokenObjectivesDispatcher, IMinigameTokenObjectivesDispatcherTrait, }; @@ -11,8 +11,8 @@ use crate::minigame::extensions::objectives::structs::GameObjectiveDetails; /// 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 lite -/// tokens (no objectives surface, no `IMINIGAME_TOKEN_OBJECTIVES_ID` +/// 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 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 ad7830c2..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,9 @@ -use game_components_embeddable_game_standard::token::extensions::settings::interface::{ +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; @@ -18,7 +18,7 @@ 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) @@ -30,11 +30,11 @@ pub fn get_settings_id(minigame_token_address: ContractAddress, token_id: felt25 /// 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). -/// Lite tokens have no settings surface at all and do not register +/// 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 lite token. +/// for every game wired to a standard token. /// /// # Arguments /// * `minigame_token_address` - The address of the minigame token contract 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/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..da547ed1 100644 --- a/packages/embeddable_game_standard/src/token/AGENTS.md +++ b/packages/embeddable_game_standard/src/token/AGENTS.md @@ -1,92 +1,128 @@ -## 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` | +| 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`. The no-arg +`initializer()` registers `IMINIGAME_TOKEN_ID` plus the absorbed minter's +`IMINIGAME_TOKEN_MINTER_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`. + +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 + +Requires: `ERC721Component`, `SRC5Component`, 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_lite/token_lite_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo similarity index 68% rename from packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo rename to packages/embeddable_game_standard/src/token/minigame_token_component.cairo index b180bd98..0bb9183c 100644 --- a/packages/embeddable_game_standard/src/token_lite/token_lite_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -1,24 +1,26 @@ -/// # CoreTokenLiteComponent +/// # MinigameTokenComponent /// -/// Single-game, storage-minimal variant of `CoreTokenComponent`, 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 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_LITE_ID`) rather than +/// ecosystem consumers via SRC5 (`IMINIGAME_TOKEN_ID`) rather than /// through address-resolution views. /// -/// The external ABI is `IMinigameTokenLite`: dead MACHINERY and compat shims +/// 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 the lite id; there is nothing to resolve. +/// 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`. @@ -29,21 +31,28 @@ /// `update_player_name`) and the mint-time `client_url` are the only /// per-token storage. /// -/// Mint parameters carry their original full-token behaviors: `objective_id`, +/// 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 — full-token parity); `client_url` is +/// only (the data is NOT stored — legacy-token parity); `client_url` is /// storage-backed with a `client_url` view. /// -/// Token ids use the lite-native 251-bit layout in `token_lite::packing` — -/// NOT the full token's `token::structs::pack_token_id` layout (which stays +/// 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 CoreTokenLiteComponent { +pub mod MinigameTokenComponent { use core::num::traits::Zero; use game_components_interfaces::structs::metagame::GameContextDetails; - use game_components_interfaces::token::lite::{IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite}; + use game_components_interfaces::token::core::{IMINIGAME_TOKEN_ID, IMinigameToken}; + use game_components_interfaces::token::minter::{ + IMINIGAME_TOKEN_MINTER_ID, IMinigameTokenMinter, + }; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; use openzeppelin_token::erc721::ERC721Component; @@ -52,25 +61,31 @@ pub mod CoreTokenLiteComponent { Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_block_timestamp, get_caller_address, get_tx_info}; - use crate::token::structs::{MintBatchRecipient, TokenMetadata}; - use crate::token::token::{LifecycleTrait, token_state}; - use crate::token::traits::OptionalMinter; - use crate::token_lite::packing::{ - extract_tx_hash_bits, pack_lite_token_id, to_token_metadata, unpack_lite_token_id, - unpack_metadata, unpack_minted_by, unpack_objective_id, unpack_settings_id, - unpack_soulbound, + 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, } #[event] #[derive(Drop, starknet::Event)] pub enum Event { MetadataUpdate: MetadataUpdate, + MinterRegistryUpdate: MinterRegistryUpdate, } /// ERC-4906 standard metadata update event @@ -80,25 +95,33 @@ pub mod CoreTokenLiteComponent { pub token_id: u256, } - #[embeddable_as(CoreTokenLiteImpl)] - pub impl CoreTokenLite< + /// 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, + } + + #[embeddable_as(MinigameTokenImpl)] + pub impl MinigameToken< TContractState, +HasComponent, impl SRC5: SRC5Component::HasComponent, impl ERC721: ERC721Component::HasComponent, - impl MinterOpt: OptionalMinter, +Drop, +ERC721Component::ERC721HooksTrait, - > of IMinigameTokenLite> { + > 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 lite id + // Its u16 `metadata` field is 0 (never a truncation): the token id // packs 65 bits — read them via `mint_metadata`. - to_token_metadata(unpack_lite_token_id(token_id)) + to_token_metadata(unpack_token_id(token_id)) } fn is_playable(self: @ComponentState, token_id: felt252) -> bool { @@ -123,8 +146,7 @@ pub mod CoreTokenLiteComponent { self: @ComponentState, token_id: felt252, ) -> ContractAddress { let minted_by_id: u64 = unpack_minted_by(token_id); - let contract_ref = self.get_contract(); - MinterOpt::get_minter_address(contract_ref, minted_by_id) + self.minter_addresses.entry(minted_by_id).read() } fn is_soulbound(self: @ComponentState, token_id: felt252) -> bool { @@ -171,7 +193,7 @@ pub mod CoreTokenLiteComponent { assert!( lifecycle.end == 0 || (lifecycle.end > current_time && lifecycle.end > lifecycle.start), - "MinigameTokenLite: Lifecycle end must be in the future and after start", + "MinigameToken: Lifecycle end must be in the future and after start", ); let effective_start = if lifecycle.start > current_time { lifecycle.start @@ -187,16 +209,15 @@ pub mod CoreTokenLiteComponent { let tx_hash_bits = extract_tx_hash_bits(get_tx_info().unbox().transaction_hash); - let mut contract_self = self.get_contract_mut(); - let minted_by = MinterOpt::add_minter(ref contract_self, caller); + let minted_by = self.add_minter(caller); // settings_id keeps its Option call-site type; the pack - // asserts the value fits the lite layout's 16-bit field. Likewise - // minted_by (u64 from OptionalMinter::add_minter) must fit 26 + // 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 - // (full-token parity: its context hook was a documented no-op). - let final_token_id = pack_lite_token_id( + // (legacy-token parity: its context hook was a documented no-op). + let final_token_id = pack_token_id( current_time, start_delay, end_delay, @@ -231,7 +252,7 @@ pub mod CoreTokenLiteComponent { /// 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 lite layout's 16-bit field). + /// `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. @@ -251,7 +272,7 @@ pub mod CoreTokenLiteComponent { metadata: u128, ) -> Array { let recipient_count = recipients.len(); - assert!(recipient_count > 0, "MinigameTokenLite: recipients array cannot be empty"); + 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; @@ -259,14 +280,14 @@ pub mod CoreTokenLiteComponent { while sum_idx < recipient_count { let r: @MintBatchRecipient = recipients.at(sum_idx); let c: u16 = *r.count; - assert!(c > 0, "MinigameTokenLite: per-recipient count must be > 0"); + 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, - "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 65535)", + "MinigameToken: salt overflow (salt + total tokens - 1 must be <= 65535)", ); // Hoisted per-batch work: lifecycle math (same rules and rationale as @@ -279,7 +300,7 @@ pub mod CoreTokenLiteComponent { assert!( lifecycle.end == 0 || (lifecycle.end > current_time && lifecycle.end > lifecycle.start), - "MinigameTokenLite: Lifecycle end must be in the future and after start", + "MinigameToken: Lifecycle end must be in the future and after start", ); let effective_start = if lifecycle.start > current_time { lifecycle.start @@ -295,12 +316,11 @@ pub mod CoreTokenLiteComponent { let tx_hash_bits = extract_tx_hash_bits(get_tx_info().unbox().transaction_hash); - let mut contract_self = self.get_contract_mut(); - let minted_by = MinterOpt::add_minter(ref contract_self, caller); + 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 (full-token parity). + // itself is NOT stored (legacy-token parity). let has_context = context.is_some(); // Per-token work: pack, optional name/url writes, ERC721 mint. @@ -314,7 +334,7 @@ pub mod CoreTokenLiteComponent { let mut k: u16 = 0; while k < count { - let final_token_id = pack_lite_token_id( + let final_token_id = pack_token_id( current_time, start_delay, end_delay, @@ -366,37 +386,92 @@ pub mod CoreTokenLiteComponent { fn update_player_name( ref self: ComponentState, token_id: felt252, name: felt252, ) { - assert!(!name.is_zero(), "MinigameTokenLite: Player name is empty"); + 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(), - "MinigameTokenLite: Caller is not owner of token", + 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() + } + } + #[generate_trait] pub impl InternalImpl< TContractState, +HasComponent, impl SRC5: SRC5Component::HasComponent, impl ERC721: ERC721Component::HasComponent, - impl MinterOpt: OptionalMinter, +Drop, +ERC721Component::ERC721HooksTrait, > of InternalTrait { - /// Registers the SRC5 interface id. There is no game argument — the - /// component is self-bound: the embedding contract is the game. Only - /// the lite id is registered; SRC5 is honest about the surface (a - /// lite token does NOT implement `IMinigameToken`). + /// 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 + } + + /// Registers the SRC5 interface ids: `IMINIGAME_TOKEN_ID` and the + /// absorbed minter's `IMINIGAME_TOKEN_MINTER_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`). fn initializer(ref self: ComponentState) { let mut contract = self.get_contract_mut(); let mut src5_component = SRC5::get_component_mut(ref contract); - src5_component.register_interface(IMINIGAME_TOKEN_LITE_ID); + 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); } /// Combined ownership + playability guard for the embedding game's @@ -408,7 +483,7 @@ pub mod CoreTokenLiteComponent { token_id: felt252, expected_owner: ContractAddress, ) { - assert!(!expected_owner.is_zero(), "MinigameTokenLite: Expected owner cannot be zero"); + 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 @@ -417,7 +492,7 @@ pub mod CoreTokenLiteComponent { let token_owner = erc721_component._owner_of(token_id.into()); assert!( token_owner == expected_owner, - "MinigameTokenLite: Address is not owner of token {}", + "MinigameToken: Address is not owner of token {}", token_id, ); self.assert_lifecycle_open(token_id); @@ -427,18 +502,18 @@ pub mod CoreTokenLiteComponent { /// 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_lite_token_id(token_id)); + 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), - "MinigameTokenLite: Token is not playable - game has not started (now={}, start={})", + "MinigameToken: Token is not playable - game has not started (now={}, start={})", current_time, lifecycle.start, ); assert!( !lifecycle.has_expired(current_time), - "MinigameTokenLite: Token is not playable - game has expired (now={}, end={})", + "MinigameToken: Token is not playable - game has expired (now={}, end={})", current_time, lifecycle.end, ); diff --git a/packages/embeddable_game_standard/src/token_lite/packing.cairo b/packages/embeddable_game_standard/src/token/packing.cairo similarity index 81% rename from packages/embeddable_game_standard/src/token_lite/packing.cairo rename to packages/embeddable_game_standard/src/token/packing.cairo index 7e8bc9ff..5c2ac31a 100644 --- a/packages/embeddable_game_standard/src/token_lite/packing.cairo +++ b/packages/embeddable_game_standard/src/token/packing.cairo @@ -1,13 +1,13 @@ // ============================================================================== -// LITE PACKED TOKEN ID - Embeds immutable data directly in the token_id (felt252) +// PACKED TOKEN ID - Embeds immutable data directly in the token_id (felt252) // ============================================================================== // -// Lite-native u128-aligned bit layout (251 bits, no field straddles the u128 -// boundary). This layout is OWNED by the lite token and is deliberately NOT the -// full token's `token::structs::pack_token_id` layout — the full layout serves -// legacy denshokan and keeps its bit positions untouched; the lite token drops +// 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 -// full token's widths (settings_id 16, salt 16, metadata 65). +// legacy token's widths (settings_id 16, salt 16, metadata 65). // Indexers must branch their decoder by contract generation. // // Low u128 (128 bits): @@ -49,13 +49,13 @@ // savings compared to u256 mask+divide unpacking. use game_components_interfaces::structs::token::{Lifecycle, TokenMetadata}; -// Shared with the full token: extracting the last 10 bits of the tx hash is +// Shared with the legacy token: extracting the last 10 bits of the tx hash is // layout-independent. -pub use crate::token::structs::extract_tx_hash_bits; +pub use crate::token_legacy::structs::extract_tx_hash_bits; -/// Data structure representing the lite packed token ID fields (for convenience). +/// Data structure representing the packed token ID fields (for convenience). #[derive(Copy, Drop, Serde)] -pub struct LitePackedTokenId { +pub struct PackedTokenId { pub minted_at: u64, // 35 bits pub start_delay: u32, // 25 bits pub end_delay: u32, // 25 bits @@ -65,7 +65,7 @@ pub struct LitePackedTokenId { 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 (full-token parity) + 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 } @@ -83,7 +83,7 @@ mod nz128 { pub const TWO_POW_35: NonZero = 0x800000000; } -/// Packs lite token metadata into a felt252 token_id using the lite-native +/// 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) @@ -91,7 +91,7 @@ mod nz128 { /// 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_lite_token_id( +pub fn pack_token_id( minted_at: u64, start_delay: u32, end_delay: u32, @@ -106,13 +106,13 @@ pub fn pack_lite_token_id( metadata: u128, ) -> felt252 { // Validate all fields fit within their bit allocations - assert!(minted_at <= 0x7FFFFFFFF, "LitePackedTokenId: minted_at exceeds 35-bit limit"); - assert!(start_delay <= 0x1FFFFFF, "LitePackedTokenId: start_delay exceeds 25-bit limit"); - assert!(end_delay <= 0x1FFFFFF, "LitePackedTokenId: end_delay exceeds 25-bit limit"); - assert!(settings_id <= 0xFFFF, "LitePackedTokenId: settings_id exceeds 16-bit limit"); - assert!(minted_by <= 0x3FFFFFF, "LitePackedTokenId: minted_by exceeds 26-bit limit"); - assert!(objective_id <= 0x3FFFFFFF, "LitePackedTokenId: objective_id exceeds 30-bit limit"); - assert!(metadata <= 0x1FFFFFFFFFFFFFFFF, "LitePackedTokenId: metadata exceeds 65-bit limit"); + 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"); // Low u128: minted_at(35) + start_delay(25) + end_delay(25) + settings_id(16) // + minted_by(26) + soulbound(1) = 128 bits @@ -132,7 +132,7 @@ pub fn pack_lite_token_id( // 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 full token's 10-bit salt it + // 16-bit field, so unlike the legacy token's 10-bit salt it // needs no mask. let paymaster_u128: u128 = if paymaster { 1 @@ -156,11 +156,11 @@ pub fn pack_lite_token_id( packed.try_into().unwrap() } -/// Unpacks a lite token_id into its component fields using DivRem chains on +/// Unpacks a token_id into its component fields using DivRem chains on /// each u128 half. metadata is the topmost high field, so it falls out as the /// final quotient. #[inline(always)] -pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { +pub fn unpack_token_id(token_id: felt252) -> PackedTokenId { let packed: u256 = token_id.into(); let low = packed.low; let high = packed.high; @@ -181,7 +181,7 @@ pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { let (hi, has_context_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); let (metadata, objective_id) = DivRem::div_rem(hi, nz128::TWO_POW_30); - LitePackedTokenId { + PackedTokenId { minted_at: minted_at.try_into().unwrap(), start_delay: start_delay.try_into().unwrap(), end_delay: end_delay.try_into().unwrap(), @@ -197,7 +197,7 @@ pub fn unpack_lite_token_id(token_id: felt252) -> LitePackedTokenId { } } -/// Helper to unpack just minted_at from a lite token_id +/// 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(); @@ -205,7 +205,7 @@ pub fn unpack_minted_at(token_id: felt252) -> u64 { minted_at.try_into().unwrap() } -/// Helper to unpack just start_delay from a lite token_id +/// 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(); @@ -214,7 +214,7 @@ pub fn unpack_start_delay(token_id: felt252) -> u32 { start_delay.try_into().unwrap() } -/// Helper to unpack just end_delay from a lite token_id +/// 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(); @@ -224,7 +224,7 @@ pub fn unpack_end_delay(token_id: felt252) -> u32 { end_delay.try_into().unwrap() } -/// Helper to unpack just settings_id from a lite token_id +/// 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(); @@ -235,7 +235,7 @@ pub fn unpack_settings_id(token_id: felt252) -> u32 { settings_id.try_into().unwrap() } -/// Helper to unpack just minted_by from a lite token_id +/// 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(); @@ -247,7 +247,7 @@ pub fn unpack_minted_by(token_id: felt252) -> u64 { minted_by.try_into().unwrap() } -/// Helper to unpack the soulbound flag from a lite token_id +/// 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(); @@ -259,7 +259,7 @@ pub fn unpack_soulbound(token_id: felt252) -> bool { soulbound_u128 == 1 } -/// Helper to unpack tx_hash from a lite token_id (last 10 bits of transaction hash) +/// 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(); @@ -267,7 +267,7 @@ pub fn unpack_tx_hash(token_id: felt252) -> u16 { tx_hash.try_into().unwrap() } -/// Helper to unpack salt from a lite token_id (client-provided collision protection) +/// 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(); @@ -276,7 +276,7 @@ pub fn unpack_salt(token_id: felt252) -> u16 { salt.try_into().unwrap() } -/// Helper to unpack the paymaster flag from a lite token_id +/// 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(); @@ -286,8 +286,8 @@ pub fn unpack_paymaster(token_id: felt252) -> bool { paymaster_u128 == 1 } -/// Helper to unpack the has_context flag from a lite token_id. The context -/// data itself is NOT stored on the token (full-token parity) — only this bit +/// 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 { @@ -299,8 +299,8 @@ pub fn unpack_has_context(token_id: felt252) -> bool { has_context_u128 == 1 } -/// Helper to unpack objective_id from a lite token_id (inert data the game -/// interprets — the lite token has no completion machinery) +/// 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(); @@ -312,7 +312,7 @@ pub fn unpack_objective_id(token_id: felt252) -> u32 { objective_id.try_into().unwrap() } -/// Helper to unpack the 65-bit metadata field from a lite token_id (inert +/// Helper to unpack the 65-bit metadata field from a token_id (inert /// data the game interprets). Topmost high field — the final quotient. #[inline(always)] pub fn unpack_metadata(token_id: felt252) -> u128 { @@ -325,21 +325,21 @@ pub fn unpack_metadata(token_id: felt252) -> u128 { metadata } -/// Convert LitePackedTokenId to the shared TokenMetadata struct. +/// Convert PackedTokenId to the shared TokenMetadata struct. /// -/// The lite token has no mutable state and never resolves a game id, so +/// 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 full +/// 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 full token's ABI, which -/// cannot change), while the lite id packs 65 bits. Read the real value via -/// `IMinigameTokenLite::mint_metadata` / `unpack_metadata`. +/// 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: LitePackedTokenId) -> TokenMetadata { +pub fn to_token_metadata(packed: PackedTokenId) -> TokenMetadata { TokenMetadata { game_id: 0, minted_at: packed.minted_at, 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_lite/tests/test_gas_bench.cairo b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo similarity index 73% rename from packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo rename to packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo index 424052c2..538ff1a1 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo @@ -1,5 +1,5 @@ -// Gas benchmarks: CoreTokenLiteComponent (self-bound in the one-address -// LiteGameMock — game and token are the same contract) vs the full +// 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). // @@ -15,10 +15,10 @@ // `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 full-token numbers. +// top of the legacy-token numbers. -use game_components_test_common::mocks::lite_game_mock::{ - ILiteGameMockDispatcher, ILiteGameMockDispatcherTrait, +use game_components_test_common::mocks::standard_game_mock::{ + IStandardGameMockDispatcher, IStandardGameMockDispatcherTrait, }; use openzeppelin_interfaces::erc721::{ERC721ABIDispatcher, ERC721ABIDispatcherTrait}; use snforge_std::{ @@ -28,7 +28,9 @@ use snforge_std::{ use starknet::ContractAddress; use crate::registry::interface::{IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait}; use crate::token::interface::{IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait}; -use crate::token_lite::interface::{IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait}; +use crate::token_legacy::interface::{ + IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, +}; const START_TIME: u64 = 1000; const END_TIME: u64 = 100000; @@ -55,29 +57,29 @@ fn deploy_mock_game() -> ContractAddress { contract_address } -/// One-address shape: the LiteGameMock contract is both the game and the +/// 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_lite() -> (IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, ContractAddress) { - let contract = declare("LiteGameMock").unwrap().contract_class(); +fn setup_standard() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddress) { + let contract = declare("StandardGameMock").unwrap().contract_class(); let mut calldata: Array = array![]; - let name: ByteArray = "LiteToken"; - let symbol: ByteArray = "LITE"; - let base_uri: ByteArray = "https://lite.test/"; + 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 (contract_address, _) = contract.deploy(@calldata).unwrap(); start_cheat_block_timestamp(contract_address, START_TIME); ( - IMinigameTokenLiteDispatcher { contract_address }, + IMinigameTokenDispatcher { contract_address }, ERC721ABIDispatcher { contract_address }, contract_address, ) } -/// Full token in the deployed-denshokan shape: multi-game registry with the +/// Legacy token in the deployed-denshokan shape: multi-game registry with the /// mock game registered, all optional extensions compiled in. -fn setup_full() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddress) { +fn setup_legacy() -> (IMinigameTokenLegacyDispatcher, ERC721ABIDispatcher, ContractAddress) { let game = deploy_mock_game(); let registry_class = declare("MinigameRegistryContract").unwrap().contract_class(); @@ -130,16 +132,16 @@ fn setup_full() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractAddre let (token_address, _) = token_class.deploy(@token_calldata).unwrap(); start_cheat_block_timestamp(token_address, START_TIME); ( - IMinigameTokenDispatcher { contract_address: token_address }, + IMinigameTokenLegacyDispatcher { contract_address: token_address }, ERC721ABIDispatcher { contract_address: token_address }, game, ) } -fn mint_lite(token: IMinigameTokenLiteDispatcher, _game: ContractAddress, salt: u16) -> felt252 { - // Lite mint — no game address (self-bound); the restored full-token +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 full-token bench call below. + // stay comparable with the legacy-token bench call below. token .mint( Option::Some('bench'), @@ -157,7 +159,7 @@ fn mint_lite(token: IMinigameTokenLiteDispatcher, _game: ContractAddress, salt: ) } -fn mint_full(token: IMinigameTokenDispatcher, game: ContractAddress, salt: u16) -> felt252 { +fn mint_legacy(token: IMinigameTokenLegacyDispatcher, game: ContractAddress, salt: u16) -> felt252 { token .mint( game, @@ -183,13 +185,13 @@ fn mint_full(token: IMinigameTokenDispatcher, game: ContractAddress, salt: u16) // ================================================================================================ #[test] -fn bench_lite_deploy_baseline() { - let (_, _, _) = setup_lite(); +fn bench_standard_deploy_baseline() { + let (_, _, _) = setup_standard(); } #[test] -fn bench_full_deploy_baseline() { - let (_, _, _) = setup_full(); +fn bench_legacy_deploy_baseline() { + let (_, _, _) = setup_legacy(); } // ================================================================================================ @@ -197,49 +199,49 @@ fn bench_full_deploy_baseline() { // ================================================================================================ #[test] -fn bench_lite_mint_x1() { - let (token, _, game) = setup_lite(); - mint_lite(token, game, 0); +fn bench_standard_mint_x1() { + let (token, _, game) = setup_standard(); + mint_standard(token, game, 0); } #[test] -fn bench_full_mint_x1() { - let (token, _, game) = setup_full(); - mint_full(token, game, 0); +fn bench_legacy_mint_x1() { + let (token, _, game) = setup_legacy(); + mint_legacy(token, game, 0); } #[test] -fn bench_lite_mint_x10() { - let (token, _, game) = setup_lite(); +fn bench_standard_mint_x10() { + let (token, _, game) = setup_standard(); let mut salt: u16 = 0; while salt < 10 { - mint_lite(token, game, salt); + mint_standard(token, game, salt); salt += 1; } } #[test] -fn bench_full_mint_x10() { - let (token, _, game) = setup_full(); +fn bench_legacy_mint_x10() { + let (token, _, game) = setup_legacy(); let mut salt: u16 = 0; while salt < 10 { - mint_full(token, game, salt); + 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 lite: assert_owner_and_playable — +// 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_lite_guard_x10() { - let (token, _, game) = setup_lite(); - let token_id = mint_lite(token, game, 0); - let game_mock = ILiteGameMockDispatcher { contract_address: token.contract_address }; +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()); @@ -248,9 +250,9 @@ fn bench_lite_guard_x10() { } #[test] -fn bench_full_guard_x10() { - let (token, erc721, game) = setup_full(); - let token_id = mint_full(token, game, 0); +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()); @@ -262,13 +264,13 @@ fn bench_full_guard_x10() { // ================================================================================================ // POST-ACTION — full: update_game (SRC5 + registry resolve + game_over + -// score callbacks + minter SRC5 probe) vs lite: refresh_metadata (event only) +// score callbacks + minter SRC5 probe) vs standard: refresh_metadata (event only) // ================================================================================================ #[test] -fn bench_lite_post_action_x10() { - let (token, _, game) = setup_lite(); - let token_id = mint_lite(token, game, 0); +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); @@ -277,9 +279,9 @@ fn bench_lite_post_action_x10() { } #[test] -fn bench_full_post_action_x10() { - let (token, _, game) = setup_full(); - let token_id = mint_full(token, game, 0); +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); diff --git a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo b/packages/embeddable_game_standard/src/token/tests/test_token.cairo similarity index 83% rename from packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo rename to packages/embeddable_game_standard/src/token/tests/test_token.cairo index 7c017ecd..e454685f 100644 --- a/packages/embeddable_game_standard/src/token_lite/tests/test_token_lite.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_token.cairo @@ -1,6 +1,9 @@ use game_components_interfaces::structs::metagame::{GameContext, GameContextDetails}; -use game_components_test_common::mocks::lite_game_mock::{ - ILiteGameMockDispatcher, ILiteGameMockDispatcherTrait, +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}; @@ -10,20 +13,17 @@ use snforge_std::{ start_cheat_transaction_hash, }; use starknet::ContractAddress; -use crate::token::extensions::minter::interface::{ - IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, -}; -use crate::token::interface::IMINIGAME_TOKEN_ID; -use crate::token::structs::MintBatchRecipient; -use crate::token_lite::interface::{ - IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, +use crate::token::interface::{ + IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; -use crate::token_lite::packing::{ - unpack_end_delay, unpack_has_context, unpack_lite_token_id, unpack_metadata, unpack_minted_at, - unpack_minted_by, unpack_objective_id, unpack_paymaster, unpack_salt, unpack_settings_id, - unpack_soulbound, unpack_start_delay, unpack_tx_hash, +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_lite::token_lite_component::CoreTokenLiteComponent; +use crate::token_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; +use crate::token_legacy::structs::MintBatchRecipient; fn addr(value: felt252) -> ContractAddress { value.try_into().unwrap() @@ -42,21 +42,21 @@ fn MINTER() -> ContractAddress { } /// Deploys ONE contract that is both the game and the token — the only -/// supported shape: the lite component is self-binding. -fn deploy_token_lite() -> ( - IMinigameTokenLiteDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, +/// supported shape: the component is self-binding. +fn deploy_token() -> ( + IMinigameTokenDispatcher, ERC721ABIDispatcher, IMinigameTokenMinterDispatcher, ) { - let contract = declare("LiteGameMock").unwrap().contract_class(); + let contract = declare("StandardGameMock").unwrap().contract_class(); let mut calldata: Array = array![]; - let name: ByteArray = "LiteToken"; - let symbol: ByteArray = "LITE"; - let base_uri: ByteArray = "https://lite.test/"; + 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 (contract_address, _) = contract.deploy(@calldata).unwrap(); ( - IMinigameTokenLiteDispatcher { contract_address }, + IMinigameTokenDispatcher { contract_address }, ERC721ABIDispatcher { contract_address }, IMinigameTokenMinterDispatcher { contract_address }, ) @@ -66,15 +66,15 @@ fn deploy_token_lite() -> ( /// 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: IMinigameTokenLiteDispatcher) -> ILiteGameMockDispatcher { - ILiteGameMockDispatcher { contract_address: token.contract_address } +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: IMinigameTokenLiteDispatcher, + token: IMinigameTokenDispatcher, player_name: Option, settings_id: Option, start: Option, @@ -115,16 +115,21 @@ fn sample_context() -> GameContextDetails { #[test] fn test_deployment_and_interfaces() { - let (token, erc721, _) = deploy_token_lite(); + let (token, erc721, _) = deploy_token(); - assert!(erc721.name() == "LiteToken", "Name mismatch"); - assert!(erc721.symbol() == "LITE", "Symbol mismatch"); + 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_LITE_ID), "Should register lite interface id"); - // SRC5 is honest: a lite token does NOT implement IMinigameToken and no - // longer advertises the legacy full-token id. - assert!(!src5.supports_interface(IMINIGAME_TOKEN_ID), "Must NOT advertise the full token id"); + 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", + ); } // ================================================================================================ @@ -133,7 +138,7 @@ fn test_deployment_and_interfaces() { #[test] fn test_mint_packs_expected_fields() { - let (token, erc721, minter) = deploy_token_lite(); + let (token, erc721, minter) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); @@ -148,7 +153,7 @@ fn test_mint_packs_expected_fields() { 7, ); - let packed = unpack_lite_token_id(token_id); + 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"); @@ -174,7 +179,7 @@ fn test_mint_packs_expected_fields() { #[test] fn test_mint_defaults_and_metadata_view() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_basic( @@ -205,7 +210,7 @@ fn test_mint_defaults_and_metadata_view() { #[test] fn test_mint_past_start_clamps_to_now() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_basic( @@ -219,7 +224,7 @@ fn test_mint_past_start_clamps_to_now() { #[test] fn test_mint_unique_ids_by_salt_and_minter() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); // Same params, same block, same caller — salt must disambiguate @@ -250,9 +255,9 @@ fn test_mint_unique_ids_by_salt_and_minter() { // ================================================================================================ #[test] -#[should_panic(expected: "MinigameTokenLite: Lifecycle end must be in the future and after start")] +#[should_panic(expected: "MinigameToken: Lifecycle end must be in the future and after start")] fn test_mint_rejects_past_end() { - let (token, _, _) = deploy_token_lite(); + 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, @@ -262,7 +267,7 @@ fn test_mint_rejects_past_end() { #[test] #[should_panic(expected: "Lifecycle: Start time cannot be greater than end time")] fn test_mint_rejects_start_after_end() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); mint_basic( token, @@ -282,7 +287,7 @@ fn test_mint_rejects_start_after_end() { #[test] fn test_playability_follows_lifecycle_window() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let game = game_of(token); start_cheat_block_timestamp(token.contract_address, 1000); @@ -310,7 +315,7 @@ fn test_playability_follows_lifecycle_window() { #[test] fn test_immortal_token_always_playable() { - let (token, _, _) = deploy_token_lite(); + 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, @@ -324,9 +329,9 @@ fn test_immortal_token_always_playable() { // ================================================================================================ #[test] -#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has expired")] +#[should_panic(expected: "MinigameToken: Token is not playable - game has expired")] fn test_guard_panics_after_expiry() { - let (token, _, _) = deploy_token_lite(); + 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, @@ -336,9 +341,9 @@ fn test_guard_panics_after_expiry() { } #[test] -#[should_panic(expected: "MinigameTokenLite: Token is not playable - game has not started")] +#[should_panic(expected: "MinigameToken: Token is not playable - game has not started")] fn test_guard_panics_before_start() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_basic( token, @@ -354,9 +359,9 @@ fn test_guard_panics_before_start() { } #[test] -#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] +#[should_panic(expected: "MinigameToken: Address is not owner of token")] fn test_guard_rejects_wrong_owner() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -364,16 +369,16 @@ fn test_guard_rejects_wrong_owner() { } #[test] -#[should_panic(expected: "MinigameTokenLite: Address is not owner of token")] +#[should_panic(expected: "MinigameToken: Address is not owner of token")] fn test_guard_rejects_nonexistent_token() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); game_of(token).assert_owner_and_playable(12345, ALICE()); } #[test] -#[should_panic(expected: "MinigameTokenLite: Expected owner cannot be zero")] +#[should_panic(expected: "MinigameToken: Expected owner cannot be zero")] fn test_guard_rejects_zero_owner() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -387,7 +392,7 @@ fn test_guard_rejects_zero_owner() { #[test] #[should_panic(expected: "Token is soulbound and cannot be transferred")] fn test_soulbound_transfer_blocked() { - let (token, erc721, _) = deploy_token_lite(); + let (token, erc721, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), true, 0, ); @@ -397,7 +402,7 @@ fn test_soulbound_transfer_blocked() { #[test] fn test_non_soulbound_transfer_allowed() { - let (token, erc721, _) = deploy_token_lite(); + let (token, erc721, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -412,7 +417,7 @@ fn test_non_soulbound_transfer_allowed() { #[test] fn test_refresh_metadata_emits_event() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -424,8 +429,8 @@ fn test_refresh_metadata_emits_event() { @array![ ( token.contract_address, - CoreTokenLiteComponent::Event::MetadataUpdate( - CoreTokenLiteComponent::MetadataUpdate { token_id: token_id.into() }, + MinigameTokenComponent::Event::MetadataUpdate( + MinigameTokenComponent::MetadataUpdate { token_id: token_id.into() }, ), ), ], @@ -434,7 +439,7 @@ fn test_refresh_metadata_emits_event() { #[test] fn test_update_player_name_by_owner() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let token_id = mint_basic( token, Option::Some('old'), Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -444,9 +449,9 @@ fn test_update_player_name_by_owner() { } #[test] -#[should_panic(expected: "MinigameTokenLite: Caller is not owner of token")] +#[should_panic(expected: "MinigameToken: Caller is not owner of token")] fn test_update_player_name_rejects_non_owner() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); let token_id = mint_basic( token, Option::None, Option::None, Option::None, Option::None, ALICE(), false, 0, ); @@ -459,7 +464,7 @@ fn test_update_player_name_rejects_non_owner() { // ================================================================================================ fn batch_neutral( - token: IMinigameTokenLiteDispatcher, recipients: Array, salt: u16, + token: IMinigameTokenDispatcher, recipients: Array, salt: u16, ) -> Array { token .mint_batch_recipients( @@ -480,7 +485,7 @@ fn batch_neutral( #[test] fn test_mint_batch_recipients_counts_owners_and_salts() { - let (token, erc721, _) = deploy_token_lite(); + let (token, erc721, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); cheat_caller_address(token.contract_address, MINTER(), CheatSpan::TargetCalls(1)); @@ -515,21 +520,19 @@ fn test_mint_batch_recipients_counts_owners_and_salts() { } #[test] -#[should_panic( - expected: "MinigameTokenLite: salt overflow (salt + total tokens - 1 must be <= 65535)", -)] +#[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_lite(); + 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_lite(); + 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 full token's 10-bit salt long ago). + // 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"); @@ -537,16 +540,16 @@ fn test_mint_batch_recipients_salt_at_16_bit_boundary() { } #[test] -#[should_panic(expected: "MinigameTokenLite: recipients array cannot be empty")] +#[should_panic(expected: "MinigameToken: recipients array cannot be empty")] fn test_mint_batch_recipients_rejects_empty() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); batch_neutral(token, array![], 0); } #[test] -#[should_panic(expected: "MinigameTokenLite: per-recipient count must be > 0")] +#[should_panic(expected: "MinigameToken: per-recipient count must be > 0")] fn test_mint_batch_recipients_rejects_zero_count() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); batch_neutral(token, array![MintBatchRecipient { to: ALICE(), count: 0 }], 0); } @@ -555,26 +558,26 @@ fn test_mint_batch_recipients_rejects_zero_count() { // ================================================================================================ /// Positive path: `assert_game_registered` now probes the token's SRC5 for -/// `IMINIGAME_TOKEN_LITE_ID` first (lite tokens expose no registry views). A -/// self-bound lite deployment IS its own game: `token_address()` returns -/// itself, the lite id matches, and the check reduces to a trivially-true +/// `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_lite_game() { - let (token, _, _) = deploy_token_lite(); +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 lite +/// 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 LiteGameMock cannot express this +/// 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 lite deployment: the SRC5 probe finds the lite +/// 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_lite_token() { - let (token, _, _) = deploy_token_lite(); +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); @@ -582,22 +585,22 @@ fn test_assert_game_registered_rejects_game_not_paired_with_lite_token() { } // ================================================================================================ -// LITE PACKING — LAYOUT AND HELPERS +// PACKING — LAYOUT AND HELPERS // ================================================================================================ #[test] fn test_helper_unpackers_agree_with_full_unpack() { - let (token, _, _) = deploy_token_lite(); + 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 lite-native 251-bit layout, so the standalone helper + // 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_lite_token_id(token_id); + 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"); @@ -616,13 +619,13 @@ fn test_helper_unpackers_agree_with_full_unpack() { /// Bit-exact layout proof: with every input pinned (including the tx hash), /// the minted id must equal the arithmetic reconstruction of the documented -/// lite layout — low: minted_at | start_delay<<35 | end_delay<<60 | +/// 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_lite_layout_bit_positions_exact() { - let (token, _, _) = deploy_token_lite(); +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)); @@ -660,12 +663,12 @@ fn test_lite_layout_bit_positions_exact() { + 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, "lite layout bit positions must match the documented table"); + 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_lite(); + 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, @@ -674,11 +677,11 @@ fn test_mint_accepts_settings_id_at_16_bit_boundary() { } #[test] -#[should_panic(expected: "LitePackedTokenId: settings_id exceeds 16-bit limit")] +#[should_panic(expected: "PackedTokenId: settings_id exceeds 16-bit limit")] fn test_mint_rejects_settings_id_over_16_bits() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); - // 0x10000 fit the full token's 30-bit field but exceeds the lite 16-bit + // 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, @@ -691,7 +694,7 @@ fn test_mint_rejects_settings_id_over_16_bits() { /// Mint helper that exercises exactly the restored params, neutral elsewhere. fn mint_restored( - token: IMinigameTokenLiteDispatcher, + token: IMinigameTokenDispatcher, objective_id: Option, context: Option, client_url: Option, @@ -720,7 +723,7 @@ fn mint_restored( /// helpers, ABI views and the shared TokenMetadata struct all agree. #[test] fn test_mint_restored_fields_roundtrip() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let token_id = mint_restored( @@ -733,7 +736,7 @@ fn test_mint_restored_fields_roundtrip() { 0xDEADBEEFCAFE, ); - let packed = unpack_lite_token_id(token_id); + 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"); @@ -746,7 +749,7 @@ fn test_mint_restored_fields_roundtrip() { // 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 lite token has no + // 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"); @@ -758,7 +761,7 @@ fn test_mint_restored_fields_roundtrip() { #[test] fn test_mint_accepts_field_boundaries() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); // Every restored field at its maximum: objective_id 2^30-1, metadata @@ -787,28 +790,28 @@ fn test_mint_accepts_field_boundaries() { } #[test] -#[should_panic(expected: "LitePackedTokenId: objective_id exceeds 30-bit limit")] +#[should_panic(expected: "PackedTokenId: objective_id exceeds 30-bit limit")] fn test_mint_rejects_objective_id_over_30_bits() { - let (token, _, _) = deploy_token_lite(); + 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: "LitePackedTokenId: metadata exceeds 65-bit limit")] +#[should_panic(expected: "PackedTokenId: metadata exceeds 65-bit limit")] fn test_mint_rejects_metadata_over_65_bits() { - let (token, _, _) = deploy_token_lite(); + 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 full token: written when +/// 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_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let with_url = mint_restored( @@ -821,11 +824,11 @@ fn test_client_url_stored_and_empty_default() { } /// context sets the id's has_context bit only — the data itself is NOT stored -/// (full-token parity: its context hook was a documented no-op and token_uri +/// (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_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let with_context = mint_restored( @@ -849,7 +852,7 @@ fn test_context_sets_has_context_bit_without_storage() { /// per token. #[test] fn test_mint_batch_shares_restored_fields_and_url() { - let (token, _, _) = deploy_token_lite(); + let (token, _, _) = deploy_token(); start_cheat_block_timestamp(token.contract_address, 1000); let ids = token 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 99% 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..13817a66 100644 --- a/packages/embeddable_game_standard/src/token/tests/setup.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo @@ -11,7 +11,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::{ 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 99% 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..cee1e84c 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; 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 99% 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..dfdda9a7 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}; 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..e34f02ae 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::{ 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 99% 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..00de4522 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, }; 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/embeddable_game_standard/src/token_lite.cairo b/packages/embeddable_game_standard/src/token_lite.cairo deleted file mode 100644 index 99e6529c..00000000 --- a/packages/embeddable_game_standard/src/token_lite.cairo +++ /dev/null @@ -1,9 +0,0 @@ -pub mod interface; -pub mod packing; - -// The deployable merged game+token mock (LiteGameMock) lives in the -// test_common package so downstream consumers can declare it via -// build-external-contracts. -#[cfg(test)] -mod tests; -pub mod token_lite_component; diff --git a/packages/embeddable_game_standard/src/token_lite/AGENTS.md b/packages/embeddable_game_standard/src/token_lite/AGENTS.md deleted file mode 100644 index c2901f5b..00000000 --- a/packages/embeddable_game_standard/src/token_lite/AGENTS.md +++ /dev/null @@ -1,121 +0,0 @@ -# Token Lite Module — CoreTokenLiteComponent (ERC721) - -Gas-optimized single-game variant of the `token` module ("denshokan lite"). -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. - -**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 lite token by SRC5 (`IMINIGAME_TOKEN_LITE_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 | -| Token id layout is lite-native | `token_lite::packing::pack_lite_token_id` (251-bit) — its OWN layout, not the full token's (`token::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 `IMinigameToken`-compatible: the full 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 full-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 — full-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) | -| 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 (lite-native, 251 bits) - -Defined in `packing.cairo` (`pack_lite_token_id` / `unpack_lite_token_id` + -per-field helpers, DivRem-chain style shared with `token::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 `OptionalMinter::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 (IMinigameTokenLite) - -**Interface ID:** `IMINIGAME_TOKEN_LITE_ID = 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea` -(derived over the trait minus `refresh_metadata`, mirroring the refresh -exclusion from `IMINIGAME_TOKEN_ID`) - -Defined in `packages/interfaces/src/token/lite.cairo`. The no-arg -`initializer()` registers ONLY `IMINIGAME_TOKEN_LITE_ID` — SRC5 is honest: a -lite token does not implement `IMinigameToken` and does not advertise the -legacy id. Consumers branch on the lite 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 lite 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 full token | -| `update_player_name` | owner-gated write | Emits `MetadataUpdate` | - -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 the lite 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 - -Requires: `ERC721Component`, `SRC5Component`, an `OptionalMinter` impl -(`MinterComponent::MinterOptionalImpl` — minter ids gate reward claims in -consumers), and an `ERC721HooksTrait` (enforce soulbound in `before_update` -via `token_lite::packing::unpack_soulbound` — pure, no storage; NOT the -full token's `unpack_soulbound`, which reads a different bit position). 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 — the former `minigame::lite::{pre_action, -post_action}` cross-contract helpers were deleted with the separate-token -shape. - -See `test_common/src/mocks/lite_game_mock.cairo` (`LiteGameMock`) 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_LITE_ID` first — a lite token means -"registered" is the self-binding equality `token_address == game_address`; -otherwise the full-token registry path runs unchanged. - -## Testing - -```bash -snforge test -p game_components_embeddable_game_standard "::token_lite::" -``` diff --git a/packages/embeddable_game_standard/src/token_lite/interface.cairo b/packages/embeddable_game_standard/src/token_lite/interface.cairo deleted file mode 100644 index b27a869c..00000000 --- a/packages/embeddable_game_standard/src/token_lite/interface.cairo +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export from interfaces package (single source of truth) -pub use game_components_interfaces::token::lite::{ - IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite, IMinigameTokenLiteDispatcher, - IMinigameTokenLiteDispatcherTrait, -}; diff --git a/packages/embeddable_game_standard/src/token_lite/tests.cairo b/packages/embeddable_game_standard/src/token_lite/tests.cairo deleted file mode 100644 index e5099565..00000000 --- a/packages/embeddable_game_standard/src/token_lite/tests.cairo +++ /dev/null @@ -1,7 +0,0 @@ -// Token lite package tests -// -// The deployable merged game+token contract (LiteGameMock) is declared from -// game_components_test_common::mocks via build-external-contracts. - -mod test_gas_bench; -mod test_token_lite; diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index 71608de7..597fb3ed 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -8,8 +8,8 @@ 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/lite` | `IMinigameTokenLite` | Gas-optimized token embedded in the game contract itself (self-bound, no registry, no mutable state) | +| `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/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 | @@ -34,7 +34,7 @@ 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_LITE_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...; @@ -162,26 +162,36 @@ 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_LITE_ID`: it is derived -over `IMinigameTokenLite` minus `refresh_metadata` (the per-selector breakdown -is kept in the doc comment above the constant in `token/lite.cairo`). +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 diff --git a/packages/interfaces/src/README.md b/packages/interfaces/src/README.md index 19661472..215509df 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 | @@ -33,6 +34,7 @@ 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..1840f5a0 100644 --- a/packages/interfaces/src/lib.cairo +++ b/packages/interfaces/src/lib.cairo @@ -107,10 +107,12 @@ pub use structs::{ // 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, - IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, IMinigameTokenObjectives, + IMINIGAME_TOKEN_CONTEXT_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, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, + IMinigameTokenLegacyDispatcherTrait, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, + IMinigameTokenMinterDispatcherTrait, IMinigameTokenObjectives, IMinigameTokenObjectivesDispatcher, IMinigameTokenObjectivesDispatcherTrait, IMinigameTokenRenderer, IMinigameTokenRendererDispatcher, IMinigameTokenRendererDispatcherTrait, IMinigameTokenSettings, IMinigameTokenSettingsDispatcher, IMinigameTokenSettingsDispatcherTrait, diff --git a/packages/interfaces/src/token.cairo b/packages/interfaces/src/token.cairo index 66d618a8..3dabc301 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -2,7 +2,7 @@ pub mod context; pub mod core; -pub mod lite; +pub mod legacy; pub mod minter; pub mod objectives; pub mod renderer; @@ -14,9 +14,9 @@ pub use context::IMINIGAME_TOKEN_CONTEXT_ID; pub use core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; -pub use lite::{ - IMINIGAME_TOKEN_LITE_ID, IMinigameTokenLite, IMinigameTokenLiteDispatcher, - IMinigameTokenLiteDispatcherTrait, +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..14e0f2a4 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,16 @@ 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; - - fn update_game(ref self: TState, token_id: felt252); + /// 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); - - // 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/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/token/lite.cairo b/packages/interfaces/src/token/lite.cairo deleted file mode 100644 index c528c244..00000000 --- a/packages/interfaces/src/token/lite.cairo +++ /dev/null @@ -1,143 +0,0 @@ -// Lite token interface — 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. -// -// Token ids use the lite-native 251-bit layout (see -// `game_components_embeddable_game_standard::token_lite::packing`), NOT the -// full 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_LITE_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 -// (`CoreTokenLiteComponent::InternalTrait`); clients use `is_playable`. -// * `refresh_metadata_batch` — gone: a multicall of singles. -// * The full 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 full-token behaviors: -// * `objective_id` — packed into the id as inert data the game interprets; -// the lite 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 (full-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 full token's u16 to a u128 holding a -// 65-bit packed field; read via `mint_metadata(token_id)`. -// -// Semantics that differ from the full 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, TokenMetadata}; - -/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors. -/// -/// Surface is the trait below minus `refresh_metadata`, mirroring the -/// refresh-function exclusion from `IMINIGAME_TOKEN_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_LITE_ID: felt252 = - 0x15951d6d145a5a13c454bd75f0787e43e531a80a4bfb42a01fc4859e6fb7aea; - -#[starknet::interface] -pub trait IMinigameTokenLite { - 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 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; - /// 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 is_soulbound(self: @TState, token_id: felt252) -> bool; - /// Packed objective id — inert data the game interprets; the lite 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; - /// 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 - /// lite 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, - 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; - /// Batch mint with per-recipient counts. Salt is a single global counter - /// across the batch (`salt + sum(counts) - 1 <= 0xFFFF` — the lite - /// 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, - 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; - /// Emits an ERC-4906 `MetadataUpdate` for `token_id` — see - /// `IMinigameToken::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); -} 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/test_common/src/AGENTS.md b/packages/test_common/src/AGENTS.md index 98eb1d4e..dc72fbed 100644 --- a/packages/test_common/src/AGENTS.md +++ b/packages/test_common/src/AGENTS.md @@ -24,7 +24,7 @@ Located in `src/mocks/`: | Mock | Purpose | |------|---------| -| `lite_game_mock.cairo` | Merged one-address game+token: embeds `CoreTokenLiteComponent` (self-bound) with `IMinigameTokenData`, `IMinigame` views, and settings | +| `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 6f8fed53..ec2adae9 100644 --- a/packages/test_common/src/mocks.cairo +++ b/packages/test_common/src/mocks.cairo @@ -1,4 +1,3 @@ -pub mod lite_game_mock; pub mod metagame_mock; pub mod minigame_mock; pub mod mock_entry_validator; @@ -12,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/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/lite_game_mock.cairo b/packages/test_common/src/mocks/standard_game_mock.cairo similarity index 78% rename from packages/test_common/src/mocks/lite_game_mock.cairo rename to packages/test_common/src/mocks/standard_game_mock.cairo index c48f00b5..3f97b327 100644 --- a/packages/test_common/src/mocks/lite_game_mock.cairo +++ b/packages/test_common/src/mocks/standard_game_mock.cairo @@ -1,23 +1,25 @@ -// Merged one-address mock: ONE contract that is both the game and the lite -// token. This is the only supported shape for `CoreTokenLiteComponent` — the -// component is self-binding, so the game contract IS the token. +// 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 + CoreTokenLiteComponent + MinterComponent, with the -// soulbound transfer guard in `before_update` (pure `unpack_soulbound` -// from the lite-native `token_lite::packing` layout). +// * 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 lite token. +// `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 -// lite announcement path. +// standard-token announcement path. #[starknet::interface] -pub trait ILiteGameMock { +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( @@ -31,7 +33,7 @@ pub trait ILiteGameMock { } #[starknet::contract] -pub mod LiteGameMock { +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; @@ -43,12 +45,11 @@ pub mod LiteGameMock { IMINIGAME_ID, IMinigame, IMinigameTokenData, }; use game_components_embeddable_game_standard::minigame::structs::MintGameParams; - use game_components_embeddable_game_standard::token::extensions::minter::minter::MinterComponent; - use game_components_embeddable_game_standard::token_lite::interface::{ - IMinigameTokenLiteDispatcher, IMinigameTokenLiteDispatcherTrait, + use game_components_embeddable_game_standard::token::interface::{ + IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; - use game_components_embeddable_game_standard::token_lite::packing::unpack_soulbound; - use game_components_embeddable_game_standard::token_lite::token_lite_component::CoreTokenLiteComponent; + use game_components_embeddable_game_standard::token::minigame_token_component::MinigameTokenComponent; + use game_components_embeddable_game_standard::token::packing::unpack_soulbound; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; use openzeppelin_token::erc721::ERC721Component; @@ -59,8 +60,7 @@ pub mod LiteGameMock { component!(path: ERC721Component, storage: erc721, event: ERC721Event); component!(path: SRC5Component, storage: src5, event: SRC5Event); - component!(path: CoreTokenLiteComponent, storage: core_token_lite, event: CoreTokenLiteEvent); - component!(path: MinterComponent, storage: minter, event: MinterEvent); + component!(path: MinigameTokenComponent, storage: minigame_token, event: MinigameTokenEvent); component!(path: SettingsComponent, storage: settings, event: SettingsEvent); #[storage] @@ -70,13 +70,11 @@ pub mod LiteGameMock { #[substorage(v0)] src5: SRC5Component::Storage, #[substorage(v0)] - core_token_lite: CoreTokenLiteComponent::Storage, - #[substorage(v0)] - minter: MinterComponent::Storage, + minigame_token: MinigameTokenComponent::Storage, #[substorage(v0)] settings: SettingsComponent::Storage, // Game state — the game contract is the sole authority on score and - // game-over; the lite token holds no mutable state. + // game-over; the standard token holds no mutable state. scores: Map, game_over: Map, // Settings storage (minigame_mock-style) @@ -95,9 +93,7 @@ pub mod LiteGameMock { #[flat] SRC5Event: SRC5Component::Event, #[flat] - CoreTokenLiteEvent: CoreTokenLiteComponent::Event, - #[flat] - MinterEvent: MinterComponent::Event, + MinigameTokenEvent: MinigameTokenComponent::Event, #[flat] SettingsEvent: SettingsComponent::Event, } @@ -109,20 +105,17 @@ pub mod LiteGameMock { #[abi(embed_v0)] impl SRC5Impl = SRC5Component::SRC5Impl; #[abi(embed_v0)] - impl CoreTokenLiteImpl = - CoreTokenLiteComponent::CoreTokenLiteImpl; + impl MinigameTokenImpl = + MinigameTokenComponent::MinigameTokenImpl; + // The minter registry is absorbed into the token component — one embed. #[abi(embed_v0)] - impl MinterImpl = MinterComponent::MinterImpl; + impl MinterImpl = MinigameTokenComponent::MinterImpl; impl ERC721InternalImpl = ERC721Component::InternalImpl; impl SRC5InternalImpl = SRC5Component::InternalImpl; - impl CoreTokenLiteInternalImpl = CoreTokenLiteComponent::InternalImpl; - impl MinterInternalImpl = MinterComponent::InternalImpl; + impl MinigameTokenInternalImpl = MinigameTokenComponent::InternalImpl; impl SettingsInternalImpl = SettingsComponent::InternalImpl; - // Minter is the only optional feature the lite core consumes. - impl MinterOptionalImpl = MinterComponent::MinterOptionalImpl; - impl ERC721HooksImpl of ERC721Component::ERC721HooksTrait { fn before_update( ref self: ERC721Component::ComponentState, @@ -154,9 +147,9 @@ pub mod LiteGameMock { ref self: ContractState, name: ByteArray, symbol: ByteArray, base_uri: ByteArray, ) { self.erc721.initializer(name, symbol, base_uri); - // Self-binding: no game argument — this contract IS the game. - self.core_token_lite.initializer(); - self.minter.initializer(); + // Self-binding: no game argument — this contract IS the game. Also + // registers the absorbed minter registry's IMINIGAME_TOKEN_MINTER_ID. + self.minigame_token.initializer(); // Registers IMINIGAME_SETTINGS_ID (mirrors minigame_mock). self.settings.initializer(); self.src5.register_interface(IMINIGAME_ID); @@ -178,13 +171,13 @@ pub mod LiteGameMock { get_contract_address() } - /// `IMinigame::mint_game` keeps the full 15-arg trait shape. The lite - /// mint now carries objective/context/client_url/paymaster/metadata - /// with their original full-token behaviors, so those forward - /// naturally (the standard trait's u16 metadata widens into the lite - /// u128 field via `.into()`); only renderer/skills — which the lite - /// token has no surface for — must be neutral, rejected here rather - /// than silently discarded. + /// `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, @@ -202,9 +195,9 @@ pub mod LiteGameMock { salt: u16, metadata: u16, ) -> felt252 { - assert!(renderer_address.is_none(), "LiteGameMock: renderer not supported"); - assert!(skills_address.is_none(), "LiteGameMock: skills not supported"); - let token = IMinigameTokenLiteDispatcher { contract_address: get_contract_address() }; + 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, @@ -223,13 +216,13 @@ pub mod LiteGameMock { } fn mint_game_batch(self: @ContractState, mints: Array) -> Array { - let token = IMinigameTokenLiteDispatcher { contract_address: get_contract_address() }; + 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(), "LiteGameMock: renderer not supported"); - assert!(m.skills_address.is_none(), "LiteGameMock: skills not supported"); + 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, @@ -312,7 +305,7 @@ pub mod LiteGameMock { } #[abi(embed_v0)] - impl LiteGameMockImpl of super::ILiteGameMock { + impl StandardGameMockImpl of super::IStandardGameMock { fn set_score(ref self: ContractState, token_id: felt252, score: u64) { self.scores.entry(token_id).write(score); } @@ -327,7 +320,7 @@ pub mod LiteGameMock { fn assert_owner_and_playable( self: @ContractState, token_id: felt252, expected_owner: ContractAddress, ) { - self.core_token_lite.assert_owner_and_playable(token_id, expected_owner); + self.minigame_token.assert_owner_and_playable(token_id, expected_owner); } fn create_settings_difficulty( @@ -347,8 +340,8 @@ pub mod LiteGameMock { // Announce to the token — i.e. this contract. The SRC5 guard in // the settings lib finds no IMINIGAME_TOKEN_SETTINGS_ID surface on - // the lite token and silently skips, mirroring minigame_mock's - // flow against a lite deployment. + // the standard token and silently skips, mirroring minigame_mock's + // flow against a standard-token deployment. self .settings .create_settings( 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; From b51c0a61d556d3145d87dfd48e8ff49acfc15a98 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:10:19 -0700 Subject: [PATCH 14/33] feat(token)!: creator payout identity on the standard token (IMinigameTokenCreator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry's game_fee_info role moves onto the self-bound token itself. With the registry retired, v2 monetization platforms (Budokan) had no way to resolve a game's payee or minimum fee — both old paths (registry game_fee_info navigation, registry-NFT-owner payee) are gone. The identity now lives on the token standard, set at initialization. New surface (packages/interfaces/src/token/creator.cairo): - GameCreatorInfo { creator, license, fee_numerator } — the registry's GameFeeInfo plus a payee; DEFAULT_GAME_FEE_BPS (500) and default_license() reused as the initializer defaults. - IMinigameTokenCreator: game_creator_info / game_creator_address (reads), set_game_creator_address / set_game_fee (writes). - IMINIGAME_TOKEN_CREATOR_ID = 0x21531ca59c09f4a8554a0c390d8054188d27b19148c9039f0279f2b66a86de7 (src5_rs; registered by the initializer alongside the minter id). Authorization: the game contract's OZ Ownable OWNER administers the surface (assert_only_owner on both setters) — the stored creator is a payout sink, not an admin. This is a HARD OwnableComponent::HasComponent bound on CreatorImpl: BREAKING for integrators — every contract embedding MinigameTokenComponent must now also embed OwnableComponent, and the initializer gains (game_creator, license: Option, fee_numerator: Option) with a non-zero creator assert. Rotation to zero is rejected (must never brick the payee); fee capped at FEE_DENOMINATOR. Consumers must resolve the payee LIVE at claim time so rotation is honoured. StandardGameMock embeds Ownable and takes (game_creator, owner) constructor params. IMINIGAME_TOKEN_ID unchanged (core trait untouched). 7 new tests; token 54 / metagame 92 / minigame 214 / presets 187 all green. Co-Authored-By: Claude Fable 5 --- .../src/token/AGENTS.md | 26 ++-- .../src/token/minigame_token_component.cairo | 117 +++++++++++++++++- .../src/token/tests/test_gas_bench.cairo | 4 + .../src/token/tests/test_token.cairo | 105 ++++++++++++++++ packages/interfaces/src/AGENTS.md | 2 + packages/interfaces/src/lib.cairo | 15 +-- packages/interfaces/src/structs.cairo | 4 +- packages/interfaces/src/structs/token.cairo | 11 ++ packages/interfaces/src/token.cairo | 5 + packages/interfaces/src/token/creator.cairo | 36 ++++++ .../src/mocks/standard_game_mock.cairo | 31 ++++- 11 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 packages/interfaces/src/token/creator.cairo diff --git a/packages/embeddable_game_standard/src/token/AGENTS.md b/packages/embeddable_game_standard/src/token/AGENTS.md index da547ed1..8ba2aae9 100644 --- a/packages/embeddable_game_standard/src/token/AGENTS.md +++ b/packages/embeddable_game_standard/src/token/AGENTS.md @@ -23,6 +23,7 @@ two-phase init, a standalone preset, game-side call helpers). | 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` | +| Creator identity is standard, not optional | The registry's `game_fee_info` role moves onto the token: `game_creator` (payout sink), license and fee (bps, default 500) are set in the initializer and served via `CreatorImpl` (`IMinigameTokenCreator`, `IMINIGAME_TOKEN_CREATOR_ID`). Setters are gated on the game contract's OZ Ownable OWNER (`assert_only_owner`, hard `OwnableComponent::HasComponent` bound) — the stored creator 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) @@ -64,12 +65,15 @@ require a new contract generation (accepted trade-off). (derived over the trait minus `refresh_metadata`, mirroring the refresh exclusion from `IMINIGAME_TOKEN_LEGACY_ID`) -Defined in `packages/interfaces/src/token/core.cairo`. The no-arg -`initializer()` registers `IMINIGAME_TOKEN_ID` plus the absorbed minter's -`IMINIGAME_TOKEN_MINTER_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. +Defined in `packages/interfaces/src/token/core.cairo`. +`initializer(game_creator, license, fee_numerator)` stores the creator +identity (creator 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 creator surface's `IMINIGAME_TOKEN_CREATOR_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 | | --- | --- | --- | @@ -85,6 +89,12 @@ The absorbed minter registry additionally exposes the unchanged `IMinigameTokenMinter` surface (`get_minter_address`, `get_minter_id`, `minter_exists`, `total_minters`) via `MinigameTokenComponent::MinterImpl`. +The creator surface (`MinigameTokenComponent::CreatorImpl`, +`IMINIGAME_TOKEN_CREATOR_ID = 0x21531ca59c09f4a8554a0c390d8054188d27b19148c9039f0279f2b66a86de7`) +exposes `game_creator_info` / `game_creator_address` (reads) and +`set_game_creator_address` / `set_game_fee` (owner-gated writes; rotation to +zero rejected, fee capped at `FEE_DENOMINATOR`). + Deleted from the ABI (strip principle — dead machinery and compat shims go, capability and read views stay): @@ -101,7 +111,9 @@ surfaces. ## Composition -Requires: `ERC721Component`, `SRC5Component`, and an `ERC721HooksTrait` +Requires: `ERC721Component`, `SRC5Component`, `OwnableComponent` (hard +`HasComponent` bound on `CreatorImpl` — the owner administers the creator +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 diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo index 0bb9183c..d05f432f 100644 --- a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -50,9 +50,15 @@ 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}; + use game_components_interfaces::token::creator::{ + DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameCreatorInfo, IMINIGAME_TOKEN_CREATOR_ID, + IMinigameTokenCreator, 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; @@ -79,6 +85,13 @@ pub mod MinigameTokenComponent { minter_counter: u64, minter_addresses: Map, minter_id_by_address: Map, + // Creator identity + 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_creator: ContractAddress, + game_creator_license: ByteArray, + game_creator_fee_numerator: u16, } #[event] @@ -86,6 +99,8 @@ pub mod MinigameTokenComponent { pub enum Event { MetadataUpdate: MetadataUpdate, MinterRegistryUpdate: MinterRegistryUpdate, + GameCreatorUpdate: GameCreatorUpdate, + GameFeeUpdate: GameFeeUpdate, } /// ERC-4906 standard metadata update event @@ -104,6 +119,20 @@ pub mod MinigameTokenComponent { pub minter_address: ContractAddress, } + /// Emitted when the creator identity is set or rotated. + #[derive(Drop, starknet::Event)] + pub struct GameCreatorUpdate { + #[key] + pub creator: 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, @@ -430,6 +459,56 @@ pub mod MinigameTokenComponent { } } + /// Creator identity 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 creator 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(CreatorImpl)] + pub impl Creator< + TContractState, + +HasComponent, + impl Own: OwnableComponent::HasComponent, + +Drop, + > of IMinigameTokenCreator> { + fn game_creator_info(self: @ComponentState) -> GameCreatorInfo { + GameCreatorInfo { + creator: self.game_creator.read(), + license: self.game_creator_license.read(), + fee_numerator: self.game_creator_fee_numerator.read(), + } + } + + fn game_creator_address(self: @ComponentState) -> ContractAddress { + self.game_creator.read() + } + + fn set_game_creator_address( + ref self: ComponentState, new_creator: ContractAddress, + ) { + Own::get_component(self.get_contract()).assert_only_owner(); + // Rotation must never brick the payee. + assert!(!new_creator.is_zero(), "MinigameToken: Creator cannot be zero"); + self.game_creator.write(new_creator); + self.emit(GameCreatorUpdate { creator: new_creator }); + } + + 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_creator_license.write(license.clone()); + self.game_creator_fee_numerator.write(fee_numerator); + self.emit(GameFeeUpdate { license, fee_numerator }); + } + } + #[generate_trait] pub impl InternalImpl< TContractState, @@ -460,18 +539,44 @@ pub mod MinigameTokenComponent { minter_id } - /// Registers the SRC5 interface ids: `IMINIGAME_TOKEN_ID` and the - /// absorbed minter's `IMINIGAME_TOKEN_MINTER_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`). - fn initializer(ref self: ComponentState) { + /// Stores the creator identity and registers the SRC5 interface ids: + /// `IMINIGAME_TOKEN_ID`, the absorbed minter's + /// `IMINIGAME_TOKEN_MINTER_ID` and the creator surface's + /// `IMINIGAME_TOKEN_CREATOR_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`). + /// + /// `game_creator` 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_creator: ContractAddress, + license: Option, + fee_numerator: Option, + ) { + assert!(!game_creator.is_zero(), "MinigameToken: Creator cannot be zero"); + let fee = fee_numerator.unwrap_or(DEFAULT_GAME_FEE_BPS); + assert!(fee <= FEE_DENOMINATOR, "MinigameToken: Fee numerator exceeds denominator"); + self.game_creator.write(game_creator); + let license_value = match license { + Option::Some(l) => l, + Option::None => default_license(), + }; + self.game_creator_license.write(license_value); + self.game_creator_fee_numerator.write(fee); + self.emit(GameCreatorUpdate { creator: game_creator }); + 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_CREATOR_ID); } /// Combined ownership + playability guard for the embedding game's 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 index 538ff1a1..0ce220a6 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo @@ -68,6 +68,10 @@ fn setup_standard() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractA name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); + let game_creator: starknet::ContractAddress = 'GAME_CREATOR'.try_into().unwrap(); + game_creator.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); ( diff --git a/packages/embeddable_game_standard/src/token/tests/test_token.cairo b/packages/embeddable_game_standard/src/token/tests/test_token.cairo index e454685f..3b15a7dd 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_token.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_token.cairo @@ -1,4 +1,8 @@ use game_components_interfaces::structs::metagame::{GameContext, GameContextDetails}; +use game_components_interfaces::token::creator::{ + DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameCreatorInfo, IMINIGAME_TOKEN_CREATOR_ID, + IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, default_license, +}; use game_components_interfaces::token::minter::{ IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, }; @@ -41,6 +45,14 @@ fn MINTER() -> ContractAddress { addr('MINTER') } +fn GAME_CREATOR() -> ContractAddress { + addr('GAME_CREATOR') +} + +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() -> ( @@ -54,6 +66,8 @@ fn deploy_token() -> ( name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); + GAME_CREATOR().serialize(ref calldata); + OWNER().serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); ( IMinigameTokenDispatcher { contract_address }, @@ -886,3 +900,94 @@ fn test_mint_batch_shares_restored_fields_and_url() { i += 1; } } + +// ================================================================================================ +// CREATOR SURFACE (owner-administered payout identity) +// ================================================================================================ + +fn creator_of(token: IMinigameTokenDispatcher) -> IMinigameTokenCreatorDispatcher { + IMinigameTokenCreatorDispatcher { contract_address: token.contract_address } +} + +#[test] +fn test_creator_registered_with_defaults() { + let (token, _, _) = deploy_token(); + let creator = creator_of(token); + + let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; + assert!( + src5.supports_interface(IMINIGAME_TOKEN_CREATOR_ID), + "Should register the creator interface id", + ); + + assert!(creator.game_creator_address() == GAME_CREATOR(), "Creator address mismatch"); + let info = creator.game_creator_info(); + let expected = GameCreatorInfo { + creator: GAME_CREATOR(), license: default_license(), fee_numerator: DEFAULT_GAME_FEE_BPS, + }; + assert!(info == expected, "Info should carry the ecosystem defaults"); +} + +#[test] +fn test_owner_rotates_creator_and_sets_fee() { + let (token, _, _) = deploy_token(); + let creator = creator_of(token); + + cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(2)); + creator.set_game_creator_address(BOB()); + creator.set_game_fee("Custom license", 1000); + + let info = creator.game_creator_info(); + assert!(info.creator == 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_creator_itself_cannot_rotate() { + // The stored creator is a payout sink, not an admin: only the contract + // owner rotates it. + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, GAME_CREATOR(), CheatSpan::TargetCalls(1)); + creator_of(token).set_game_creator_address(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)); + creator_of(token).set_game_fee("hijack", 0); +} + +#[test] +#[should_panic(expected: "MinigameToken: Creator cannot be zero")] +fn test_rotation_to_zero_rejected() { + let (token, _, _) = deploy_token(); + cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(1)); + creator_of(token).set_game_creator_address(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)); + creator_of(token).set_game_fee("too greedy", FEE_DENOMINATOR + 1); +} + +#[test] +fn test_zero_creator_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 creator must fail the constructor"); +} diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index 597fb3ed..8e540e92 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -9,6 +9,7 @@ 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` (`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/creator` | `IMinigameTokenCreator` | Creator payout identity + fee terms 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 | @@ -36,6 +37,7 @@ 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_CREATOR_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 1840f5a0..4c23e23a 100644 --- a/packages/interfaces/src/lib.cairo +++ b/packages/interfaces/src/lib.cairo @@ -99,17 +99,18 @@ 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, GameCreatorInfo, GameDetail, GameFeeInfo, 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_LEGACY_ID, - IMINIGAME_TOKEN_MINTER_ID, IMINIGAME_TOKEN_OBJECTIVES_ID, IMINIGAME_TOKEN_RENDERER_ID, - IMINIGAME_TOKEN_SETTINGS_ID, IMinigameToken, IMinigameTokenDispatcher, + IMINIGAME_TOKEN_CONTEXT_ID, IMINIGAME_TOKEN_CREATOR_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, IMinigameTokenCreator, + IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, IMinigameTokenMinterDispatcherTrait, IMinigameTokenObjectives, diff --git a/packages/interfaces/src/structs.cairo b/packages/interfaces/src/structs.cairo index b3788fca..4fcd7e5a 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, + GameCreatorInfo, 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..d8bb9097 100644 --- a/packages/interfaces/src/structs/token.cairo +++ b/packages/interfaces/src/structs/token.cairo @@ -3,6 +3,17 @@ use starknet::ContractAddress; use super::metagame::GameContextDetails; +/// Creator identity + monetization terms of a self-bound standard token. +/// Replaces the retired registry's `GameFeeInfo` lookup: the payee and fee +/// live on the game/token contract itself (see `token::creator`). +#[derive(Drop, Serde, Clone, PartialEq)] +pub struct GameCreatorInfo { + pub creator: 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 3dabc301..8b9b6fdf 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -2,6 +2,7 @@ pub mod context; pub mod core; +pub mod creator; pub mod legacy; pub mod minter; pub mod objectives; @@ -14,6 +15,10 @@ pub use context::IMINIGAME_TOKEN_CONTEXT_ID; pub use core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; +pub use creator::{ + IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreator, IMinigameTokenCreatorDispatcher, + IMinigameTokenCreatorDispatcherTrait, +}; pub use legacy::{ IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, diff --git a/packages/interfaces/src/token/creator.cairo b/packages/interfaces/src/token/creator.cairo new file mode 100644 index 00000000..2a3f3a2a --- /dev/null +++ b/packages/interfaces/src/token/creator.cairo @@ -0,0 +1,36 @@ +// Token creator extension interface +// +// The registry used to carry a game's creator identity and monetization fee +// (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 identity +// lives on the token standard itself: set at initialization, administered by +// the game contract's OZ Ownable OWNER (the stored creator is a payout sink +// only), 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, default_license}; +pub use crate::structs::token::GameCreatorInfo; + +/// SNIP-5 interface ID derived via src5_rs: XOR of extended function selectors +/// - game_creator_info()->(ContractAddress,(Array,felt252,usize),u16) +/// 0x1879f9741e7b592cc8da6ca5d9cf83ad687f91b87761744cd80f7a36deed4e +/// - game_creator_address()->ContractAddress +/// 0x303788bd08cbf196171a0b6fcb0c815715fb3916ee1c10b3d67d6a842650b1e +/// - set_game_creator_address(ContractAddress) +/// 0x23fb1fc00d3dc31c6c8b33b1a262d95b2fff025a12cef8aaf076874fdcf7255 +/// - set_game_fee((Array,felt252,usize),u16) +/// 0x3318144fd81873b0e256922d3972f42e61a473c6336dfcc2e9f2e8defdcf9e2 +pub const IMINIGAME_TOKEN_CREATOR_ID: felt252 = + 0x21531ca59c09f4a8554a0c390d8054188d27b19148c9039f0279f2b66a86de7; + +#[starknet::interface] +pub trait IMinigameTokenCreator { + fn game_creator_info(self: @TState) -> GameCreatorInfo; + fn game_creator_address(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_creator_address(ref self: TState, new_creator: 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/test_common/src/mocks/standard_game_mock.cairo b/packages/test_common/src/mocks/standard_game_mock.cairo index 3f97b327..4d191558 100644 --- a/packages/test_common/src/mocks/standard_game_mock.cairo +++ b/packages/test_common/src/mocks/standard_game_mock.cairo @@ -50,6 +50,7 @@ pub mod StandardGameMock { }; 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; @@ -62,6 +63,9 @@ pub mod StandardGameMock { 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 CreatorImpl (hard HasComponent bound): + // the creator surface is administered by the contract owner. + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent); #[storage] struct Storage { @@ -73,6 +77,8 @@ pub mod StandardGameMock { 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, @@ -96,6 +102,8 @@ pub mod StandardGameMock { MinigameTokenEvent: MinigameTokenComponent::Event, #[flat] SettingsEvent: SettingsComponent::Event, + #[flat] + OwnableEvent: OwnableComponent::Event, } #[abi(embed_v0)] @@ -107,14 +115,20 @@ pub mod StandardGameMock { #[abi(embed_v0)] impl MinigameTokenImpl = MinigameTokenComponent::MinigameTokenImpl; - // The minter registry is absorbed into the token component — one embed. + // The minter registry and creator surface are absorbed into the token + // component — plain embeds, no separate components. #[abi(embed_v0)] impl MinterImpl = MinigameTokenComponent::MinterImpl; + #[abi(embed_v0)] + impl CreatorImpl = MinigameTokenComponent::CreatorImpl; + #[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( @@ -144,12 +158,21 @@ pub mod StandardGameMock { #[constructor] fn constructor( - ref self: ContractState, name: ByteArray, symbol: ByteArray, base_uri: ByteArray, + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + base_uri: ByteArray, + game_creator: ContractAddress, + owner: ContractAddress, ) { self.erc721.initializer(name, symbol, base_uri); + // The owner administers the creator 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. - self.minigame_token.initializer(); + // registers the absorbed minter registry's IMINIGAME_TOKEN_MINTER_ID + // and the creator surface's IMINIGAME_TOKEN_CREATOR_ID (creator set + // here; license/fee left to the ecosystem defaults). + self.minigame_token.initializer(game_creator, Option::None, Option::None); // Registers IMINIGAME_SETTINGS_ID (mirrors minigame_mock). self.settings.initializer(); self.src5.register_interface(IMINIGAME_ID); From 465ce930f10c3066724a26a6f630d28a68b05090 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:13:36 -0700 Subject: [PATCH 15/33] =?UTF-8?q?perf(token):=20adopt=20SDM's=20packing=20?= =?UTF-8?q?codec=20=E2=80=94=20felt-arithmetic=20pack,=20u64=20word-split?= =?UTF-8?q?=20unpack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same method next-death-mountain uses for its model packing, ported to the standard token id codec. The bit LAYOUT is unchanged — ids, ABI, interface ids and downstream decoders are all untouched; this is implementation-only. - pack_token_id: pure felt252 arithmetic (a valid id is <= 251 bits < P, so felt add/mul is exact) — no u128 multiplications, no u256 assembly. - unpack_token_id: each u128 half splits ONCE at a field-aligned boundary (low at bit 60, high at bit 58 where 65-bit metadata falls out as the quotient), then all field extractions run as u64 DivRems. 3 u128 + 6 u64 DivRems, was 10 u128. - per-field helpers: one shift-to-bottom u128 DivRem + at most two u64 ops (soulbound/metadata collapse to a single quotient). Bench (l2_gas): mint 2,580,763 -> 2,455,483 (-4.9%); batch mint -125k/token; guard -14.5k/call; post_action -12.5k/call. token 54/54, metagame 92/92. Co-Authored-By: Claude Fable 5 --- .../src/token/packing.cairo | 249 +++++++++++------- 1 file changed, 148 insertions(+), 101 deletions(-) diff --git a/packages/embeddable_game_standard/src/token/packing.cairo b/packages/embeddable_game_standard/src/token/packing.cairo index 5c2ac31a..c2b1d883 100644 --- a/packages/embeddable_game_standard/src/token/packing.cairo +++ b/packages/embeddable_game_standard/src/token/packing.cairo @@ -45,8 +45,19 @@ // - salt: Client-provided value for multicall scenarios. Client must increment // salt for each mint within the same transaction to avoid collisions. // -// All DivRem operations use native u128_safe_divmod Sierra hints for ~64% gas -// savings compared to u256 mask+divide unpacking. +// 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 @@ -70,17 +81,49 @@ pub struct PackedTokenId { pub metadata: u128 // 65 bits - inert data the game interprets } -/// NonZero constants for DivRem-based unpacking. -/// Each constant is a power of 2 matching a field width. -/// DivRem extracts field (remainder) and shifts (quotient) in one operation. +/// 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_1: NonZero = 0x2; pub const TWO_POW_10: NonZero = 0x400; - pub const TWO_POW_16: NonZero = 0x10000; pub const TWO_POW_25: NonZero = 0x2000000; - pub const TWO_POW_26: NonZero = 0x4000000; - pub const TWO_POW_30: NonZero = 0x40000000; 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 @@ -114,84 +157,89 @@ pub fn pack_token_id( assert!(objective_id <= 0x3FFFFFFF, "PackedTokenId: objective_id exceeds 30-bit limit"); assert!(metadata <= 0x1FFFFFFFFFFFFFFFF, "PackedTokenId: metadata exceeds 65-bit limit"); - // Low u128: minted_at(35) + start_delay(25) + end_delay(25) + settings_id(16) - // + minted_by(26) + soulbound(1) = 128 bits - let soulbound_u128: u128 = if soulbound { + // 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 low: u128 = Into::::into(minted_at) - + Into::::into(start_delay) * 0x800000000_u128 // shift 35 - + Into::::into(end_delay) * 0x1000000000000000_u128 // shift 60 - + Into::::into(settings_id) * 0x2000000000000000000000_u128 // shift 85 - + Into::::into(minted_by) * 0x20000000000000000000000000_u128 // shift 101 - + soulbound_u128 * 0x80000000000000000000000000000000_u128; // 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 paymaster_u128: u128 = if paymaster { + let paymaster_f: felt252 = if paymaster { 1 } else { 0 }; - let has_context_u128: u128 = if has_context { + let has_context_f: felt252 = if has_context { 1 } else { 0 }; - let high: u128 = Into::::into(tx_hash & 0x3FF) - + Into::::into(salt) * 0x400_u128 // shift 10 - + paymaster_u128 * 0x4000000_u128 // shift 26 - + has_context_u128 * 0x8000000_u128 // shift 27 - + Into::::into(objective_id) * 0x10000000_u128 // shift 28 - + metadata * 0x400000000000000_u128; // shift 58 + // 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; - let packed = u256 { low, high }; - packed.try_into().unwrap() + low + high * felt_shift::SHIFT_128 } -/// Unpacks a token_id into its component fields using DivRem chains on -/// each u128 half. metadata is the topmost high field, so it falls out as the -/// final quotient. +/// 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(); - let low = packed.low; - let high = packed.high; - // Unpack low u128: minted_at(35) | start_delay(25) | end_delay(25) - // | settings_id(16) | minted_by(26) | soulbound(1) - let (hi, minted_at) = DivRem::div_rem(low, nz128::TWO_POW_35); - let (hi, start_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, end_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, settings_id) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (soulbound_u128, minted_by) = DivRem::div_rem(hi, nz128::TWO_POW_26); + // 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); - // Unpack high u128: tx_hash(10) | salt(16) | paymaster(1) | has_context(1) - // | objective_id(30) | metadata(65, final quotient) - let (hi, tx_hash) = DivRem::div_rem(high, nz128::TWO_POW_10); - let (hi, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (hi, paymaster_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (hi, has_context_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (metadata, objective_id) = DivRem::div_rem(hi, nz128::TWO_POW_30); + // 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: minted_at.try_into().unwrap(), + 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: minted_by.try_into().unwrap(), - soulbound: soulbound_u128 == 1, + minted_by, + soulbound: soulbound_u64 == 1, tx_hash: tx_hash.try_into().unwrap(), salt: salt.try_into().unwrap(), - paymaster: paymaster_u128 == 1, - has_context: has_context_u128 == 1, + paymaster: paymaster_u64 == 1, + has_context: has_context_u64 == 1, objective_id: objective_id.try_into().unwrap(), metadata, } @@ -209,8 +257,10 @@ pub fn unpack_minted_at(token_id: felt252) -> u64 { #[inline(always)] pub fn unpack_start_delay(token_id: felt252) -> u32 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); - let (_, start_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + // 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() } @@ -218,9 +268,9 @@ pub fn unpack_start_delay(token_id: felt252) -> u32 { #[inline(always)] pub fn unpack_end_delay(token_id: felt252) -> u32 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (_, end_delay) = DivRem::div_rem(hi, nz128::TWO_POW_25); + // 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() } @@ -228,10 +278,11 @@ pub fn unpack_end_delay(token_id: felt252) -> u32 { #[inline(always)] pub fn unpack_settings_id(token_id: felt252) -> u32 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (_, settings_id) = DivRem::div_rem(hi, nz128::TWO_POW_16); + // 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() } @@ -239,23 +290,20 @@ pub fn unpack_settings_id(token_id: felt252) -> u32 { #[inline(always)] pub fn unpack_minted_by(token_id: felt252) -> u64 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (_, minted_by) = DivRem::div_rem(hi, nz128::TWO_POW_26); - minted_by.try_into().unwrap() + // 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(); - let (hi, _) = DivRem::div_rem(packed.low, nz128::TWO_POW_35); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_25); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (soulbound_u128, _) = DivRem::div_rem(hi, nz128::TWO_POW_26); + // 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 } @@ -271,8 +319,11 @@ pub fn unpack_tx_hash(token_id: felt252) -> u16 { #[inline(always)] pub fn unpack_salt(token_id: felt252) -> u16 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); - let (_, salt) = DivRem::div_rem(hi, nz128::TWO_POW_16); + // 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() } @@ -280,10 +331,11 @@ pub fn unpack_salt(token_id: felt252) -> u16 { #[inline(always)] pub fn unpack_paymaster(token_id: felt252) -> bool { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (_, paymaster_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); - paymaster_u128 == 1 + 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 @@ -292,11 +344,11 @@ pub fn unpack_paymaster(token_id: felt252) -> bool { #[inline(always)] pub fn unpack_has_context(token_id: felt252) -> bool { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (_, has_context_u128) = DivRem::div_rem(hi, nz128::TWO_POW_1); - has_context_u128 == 1 + 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 @@ -304,24 +356,19 @@ pub fn unpack_has_context(token_id: felt252) -> bool { #[inline(always)] pub fn unpack_objective_id(token_id: felt252) -> u32 { let packed: u256 = token_id.into(); - let (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (_, objective_id) = DivRem::div_rem(hi, nz128::TWO_POW_30); + // 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 — the final quotient. +/// 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 (hi, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_10); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_16); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (hi, _) = DivRem::div_rem(hi, nz128::TWO_POW_1); - let (metadata, _) = DivRem::div_rem(hi, nz128::TWO_POW_30); + let (metadata, _) = DivRem::div_rem(packed.high, nz128::TWO_POW_58); metadata } From 2939fc940cdfdf37dd97a009227cf2e495a5c674 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:53:36 -0700 Subject: [PATCH 16/33] fix(metagame): serve both token generations, not just legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert_game_registered` was widened to accept self-bound standard tokens, but every path behind that gate still spoke the legacy ABI — so an accepted game passed the gate and reverted at mint. The component's initializer also rejected standard tokens outright, so such a metagame could not even be constructed. All four entry points now branch on SRC5: * `initializer` accepts a default token supporting either `IMINIGAME_TOKEN_LEGACY_ID` or `IMINIGAME_TOKEN_ID`. * `mint` / `mint_batch` route to the standard 12-arg `mint` for standard tokens. The unsupported `renderer_address` / `skills_address` params are rejected loudly rather than silently dropped. * `get_game_fee_info` / `pay_game_fee` read the token's creator surface (`game_creator_info` / `game_creator_address`) when it advertises `IMINIGAME_TOKEN_CREATOR_ID`, keeping the registry -> NFT-owner walk for legacy tokens. * The zero-registry legacy branch now compares the token's own `game_address()` to the game, instead of asserting token == game. A legacy token is a separate contract from its game, so the old equality could never hold for a single-game legacy deployment. Every remaining legacy dispatcher call sits in a legacy-only fallback branch. Tests: 10 new cases in `metagame::tests::test_libs` — the standard-token mint/fee/registration paths run against the real merged game+token contract (`StandardGameMock`) rather than a mock ABI, plus the legacy single-game pairing including its mispaired negative case. Known limitation: `MetagameComponent::mint` still takes `metadata: u16`, so a metagame cannot reach the standard token's 65-bit metadata field. Widening it is a breaking metagame ABI change, left as a follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 36 ++- Scarb.toml | 1 - codecov.yml | 4 +- .../src/metagame/metagame.cairo | 154 +++++++++++- .../src/metagame/metagame_component.cairo | 33 +-- .../src/metagame/tests/test_libs.cairo | 230 ++++++++++++++++++ packages/presets/src/lib.cairo | 22 +- 7 files changed, 436 insertions(+), 44 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 58d53650..7cd1fb20 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,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) @@ -93,6 +124,9 @@ 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. + ## Key Patterns - `#[starknet::component]` for reusable architecture diff --git a/Scarb.toml b/Scarb.toml index 51de9f28..688e96f1 100644 --- a/Scarb.toml +++ b/Scarb.toml @@ -38,7 +38,6 @@ starknet = "2.16.1" snforge_std = { git = "https://github.com/foundry-rs/starknet-foundry", tag = "v0.58.1" } openzeppelin_access = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_introspection = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } -openzeppelin_upgrades = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_token = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } openzeppelin_interfaces = { git = "https://github.com/OpenZeppelin/cairo-contracts.git", tag = "v3.0.0" } ekubo = { git = "https://github.com/EkuboProtocol/starknet-contracts.git", tag = "v4.0.1" } diff --git a/codecov.yml b/codecov.yml index b85621b5..69ecbf57 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,8 +1,8 @@ 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 + # 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: diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 35354b8a..191b1630 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -9,7 +9,14 @@ use game_components_embeddable_game_standard::registry::interface::{ use game_components_embeddable_game_standard::token_legacy::interface::{ IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, }; -use game_components_interfaces::token::core::IMINIGAME_TOKEN_ID; +use game_components_interfaces::token::core::{ + IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, +}; +use game_components_interfaces::token::creator::{ + IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreatorDispatcher, + IMinigameTokenCreatorDispatcherTrait, +}; +use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use starknet::ContractAddress; use crate::metagame::structs::MintMetagameParams; @@ -21,9 +28,12 @@ use crate::metagame::structs::MintMetagameParams; /// 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, and a zero -/// `game_registry_address()` (single-game legacy token) again means the mutual -/// pairing is the check. +/// 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 /// * `game_address` - The address of the game contract to check @@ -40,7 +50,9 @@ pub fn assert_game_registered(game_address: ContractAddress) { }; let minigame_registry_address = minigame_token_dispatcher.game_registry_address(); if minigame_registry_address.is_zero() { - assert!(minigame_token_address == game_address, "Game is not registered"); + assert!( + minigame_token_dispatcher.game_address() == game_address, "Game is not registered", + ); return; } let minigame_registry_dispatcher = IMinigameRegistryDispatcher { @@ -50,6 +62,57 @@ pub fn assert_game_registered(game_address: ContractAddress) { assert!(game_exists, "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) +} + +/// 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: u16, +) -> 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.into(), + ) +} + /// Mints a game token through the minigame token contract /// /// # Arguments @@ -93,6 +156,28 @@ pub fn mint( Option::Some(game_address) => { 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. + if is_standard_token(minigame_token_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, + ); + } let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; @@ -118,6 +203,28 @@ pub fn mint( // If no game address is provided, mint a token through the default token contract (blank // game) Option::None => { + // The default token may itself be a standard (self-bound) token: + // there is no blank-game concept there, the mint simply belongs to + // that token's own game. + if is_standard_token(default_token_address) { + return mint_standard_token( + default_token_address, + player_name, + settings_id, + start, + end, + objective_id, + context, + client_url, + renderer_address, + skills_address, + to, + soulbound, + paymaster, + salt, + metadata, + ); + } let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: default_token_address, }; @@ -211,10 +318,19 @@ 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 creator identity the registry used to hold, so a +/// token advertising `IMINIGAME_TOKEN_CREATOR_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(); + if supports_creator_surface(minigame_token_address) { + let info = IMinigameTokenCreatorDispatcher { contract_address: minigame_token_address } + .game_creator_info(); + return GameFeeInfo { license: info.license, fee_numerator: info.fee_numerator }; + } let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { contract_address: minigame_token_address, }; @@ -225,3 +341,29 @@ pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { 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 creator surface +/// (`IMINIGAME_TOKEN_CREATOR_ID`) that replaced the registry's fee/payee role. +pub fn supports_creator_surface(token_address: ContractAddress) -> bool { + ISRC5Dispatcher { contract_address: token_address } + .supports_interface(IMINIGAME_TOKEN_CREATOR_ID) +} + +/// Resolves the address that should receive a game's creator fee. +/// +/// Standard tokens name the payee directly (`game_creator_address`). Legacy +/// registry tokens keep the old indirection: the payee is whoever currently +/// owns the game's registry NFT. +pub fn get_game_creator_address(game_address: ContractAddress) -> ContractAddress { + let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; + let token_address = minigame_dispatcher.token_address(); + if supports_creator_surface(token_address) { + return IMinigameTokenCreatorDispatcher { contract_address: token_address } + .game_creator_address(); + } + let token_dispatcher = IMinigameTokenLegacyDispatcher { 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); + 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 f17dbd02..c17f1994 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -7,8 +7,8 @@ pub mod MetagameComponent { 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_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; + use game_components_interfaces::token::core::IMINIGAME_TOKEN_ID; 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::{ @@ -19,11 +19,6 @@ pub mod MetagameComponent { 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_legacy::interface::{ - IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, - }; #[storage] pub struct Storage { @@ -75,10 +70,13 @@ pub mod MetagameComponent { Option::None => {}, } assert!(!default_token_address.is_zero(), "Metagame: Default token address is zero"); + // Either token generation is a valid default: a legacy + // (registry-backed) token, or a self-bound standard token. let minigame_dispatcher = ISRC5Dispatcher { contract_address: default_token_address }; assert!( - minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_LEGACY_ID), - "Metagame: Default token contract does not support IMinigameTokenLegacy", + minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_LEGACY_ID) + || minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_ID), + "Metagame: Default token contract supports neither IMinigameToken nor IMinigameTokenLegacy", ); self.default_token_address.write(default_token_address); } @@ -152,21 +150,10 @@ 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 = IMinigameTokenLegacyDispatcher { - 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); - - // Get creator token owner via ERC721 owner_of - let erc721 = IERC721Dispatcher { contract_address: registry_address }; - let recipient = erc721.owner_of(game_id.into()); + // 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_creator_address(game_address); // Transfer fee let erc20 = IERC20Dispatcher { contract_address: payment_token }; 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 6ac229e6..d01c4d1f 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -1942,3 +1942,233 @@ mod MockMinigameTokenWithRegistry { } } } + +// ============================================================================= +// 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::creator::{ + IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, + }; + 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_creator: 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_creator.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, // default token (unused on this branch) + Option::Some(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"); + } + + /// With no game address the default token is used directly — a standard + /// token has no blank-game concept, the mint belongs to its own game. + #[test] + fn test_mint_defaults_to_standard_token() { + 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, + 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::Some(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::Some(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 creator 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_creator_surface() { + let game = deploy_standard_game(ALICE()); + let fee_info = libs::get_game_fee_info(game); + let declared = IMinigameTokenCreatorDispatcher { contract_address: game }; + assert!( + fee_info.fee_numerator == declared.game_creator_info().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_creator_address_is_the_declared_payee() { + let game = deploy_standard_game(ALICE()); + assert!(libs::get_game_creator_address(game) == ALICE(), "payee is not the declared creator"); + } +} + +// ============================================================================= +// 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 super::{deploy_mock_minigame_for_registry, deploy_mock_token_with_registry}; + use crate::metagame::metagame as libs; + + #[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); + } + + #[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); + } +} diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index d7907891..6b211908 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -1,18 +1,18 @@ // 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 + pub mod autonomous_buyback; pub mod leaderboard; -/// # 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 stream_token; pub use autonomous_buyback::AutonomousBuyback; From 4a9bf150855edd3513a449833d9acb751cdd0f90 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:31:32 -0700 Subject: [PATCH 17/33] refactor(metagame)!: drop the metagame-wide default token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every game brings its own token, so a default token had nothing to point at. The token is now resolved from `game_address` on every mint. BREAKING: * `IMetagame` loses `default_token_address()`, so IMETAGAME_ID changes from 0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2 to 0x1363c8de5144122290d663c4c7a10d09518fbe76475610a7027ea4770b9c179 (rederived with src5_rs; regenerating the old two-method trait reproduces the old constant exactly, confirming the derivation). Consumers probing the old id must update. * `initializer(context_address)` no longer takes a token, and no longer SRC5-validates one at construction — a bad game address now surfaces at first mint instead. * `mint`, `mint_batch` and `MintMetagameParams.game_address` take a REQUIRED `ContractAddress`. The blank-game mint (game_address = 0 against a default token) no longer exists as a capability. * `MetagameCallbackComponent::initializer(token_address)` binds its own legacy token. Callbacks fire from `update_game()`, which the standard token does not have, so the extension is legacy-only and owns the binding — it no longer depends on MetagameComponent, and its guard is a storage read rather than a cross-component call. `context_address()` was verified unused outside the component: the legacy token takes context as a mint parameter and never resolves a provider address. Tests: full package 1197 passed / 0 failed. The obsolete `test_minigame_token_address_view` is removed and a zero-token guard test added for the callback initializer, leaving the marker count unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/AGENTS.md | 34 +++- .../extensions/callback/callback.cairo | 36 ++-- .../src/metagame/metagame.cairo | 171 ++++++------------ .../src/metagame/metagame_component.cairo | 26 +-- .../src/metagame/structs.cairo | 2 +- .../src/metagame/tests/test_callback.cairo | 25 ++- .../tests/test_fuzz_mint_parameters.cairo | 12 +- .../src/metagame/tests/test_libs.cairo | 156 ++++++++-------- .../tests/test_metagame_component.cairo | 96 +++++----- .../metagame/tests/test_tournament_flow.cairo | 16 +- .../src/token_legacy/tests/test_context.cairo | 28 +-- .../tests/test_context_coverage.cairo | 12 +- .../tests/test_examples_coverage.cairo | 2 +- .../token_legacy/tests/test_integration.cairo | 10 +- packages/interfaces/src/metagame/core.cairo | 10 +- .../test_common/src/mocks/metagame_mock.cairo | 8 +- 16 files changed, 309 insertions(+), 335 deletions(-) diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index 5779b991..b7320f8c 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -7,11 +7,13 @@ 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 + context_address: ContractAddress, // Optional IMetagameContext } ``` +There is no metagame-wide default token: every game brings its own, so the +token is resolved from `game_address` on each mint. + ## Interfaces ### IMetagame (Read-only) @@ -19,15 +21,17 @@ pub struct Storage { | Method | Returns | Description | |--------|---------|-------------| | `context_address()` | `ContractAddress` | Optional context contract (tournaments/events) | -| `default_token_address()` | `ContractAddress` | Default MinigameToken for minting | -**Interface ID**: `0x0260d5160a283a03815f6c3799926c7bdbec5f22e759f992fb8faf172243ab20` +**Interface ID**: `0x1363c8de5144122290d663c4c7a10d09518fbe76475610a7027ea4770b9c179` + +Removing `default_token_address()` changed the id — consumers probing the +previous value must update. ### InternalTrait (Component internals) | Method | Description | |--------|-------------| -| `initializer(context_address, default_token_address)` | Initialize with optional context | +| `initializer(context_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 | @@ -83,21 +87,31 @@ mod MyMetagame { ``` Metagame - |-- default_token_address --> MinigameToken (REQUIRED) |-- context_address --------> IMetagameContext (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_LEGACY_ID` (the legacy multi-game token) -- `context_address` (if provided) MUST support `IMETAGAME_CONTEXT_ID` -- Both addresses validated via SRC5 introspection on init +- `context_address` (if provided) MUST support `IMETAGAME_CONTEXT_ID`, validated via SRC5 on init + +## 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, 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/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 191b1630..9ac30d8d 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -50,9 +50,7 @@ pub fn assert_game_registered(game_address: ContractAddress) { }; 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", - ); + assert!(minigame_token_dispatcher.game_address() == game_address, "Game is not registered"); return; } let minigame_registry_dispatcher = IMinigameRegistryDispatcher { @@ -91,10 +89,7 @@ fn mint_standard_token( salt: u16, metadata: u16, ) -> felt252 { - assert!( - renderer_address.is_none(), - "Metagame: standard tokens have no per-token renderer", - ); + 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( @@ -113,10 +108,12 @@ fn mint_standard_token( ) } -/// Mints a game token through the minigame token contract +/// 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 @@ -133,8 +130,7 @@ fn mint_standard_token( /// # 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, @@ -150,117 +146,63 @@ pub fn mint( salt: u16, metadata: u16, ) -> 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(); - // 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. - if is_standard_token(minigame_token_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, - ); - } - 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, - metadata, - ) - }, - // If no game address is provided, mint a token through the default token contract (blank - // game) - Option::None => { - // The default token may itself be a standard (self-bound) token: - // there is no blank-game concept there, the mint simply belongs to - // that token's own game. - if is_standard_token(default_token_address) { - return mint_standard_token( - default_token_address, - player_name, - settings_id, - start, - end, - objective_id, - context, - client_url, - renderer_address, - skills_address, - to, - soulbound, - paymaster, - salt, - metadata, - ); - } - let minigame_token_dispatcher = IMinigameTokenLegacyDispatcher { - 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. + if is_standard_token(minigame_token_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, + ); } + 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, + metadata, + ) } -/// Mints multiple game tokens in batch through minigame token contracts +/// Mints multiple game tokens in batch through their games' token contracts +/// +/// Each entry names its own game; the token is resolved per mint. /// /// # Arguments -/// * `default_token_address` - The default token address for minting when no game_address is -/// provided * `mints` - Array of mint parameters for each token +/// * `mints` - Array of mint parameters for each token /// /// # Returns /// * `Array` - Array of minted token IDs -pub fn mint_batch( - default_token_address: ContractAddress, mints: Array, -) -> Array { +pub fn mint_batch(mints: Array) -> Array { let mut token_ids = array![]; let mut index = 0; @@ -283,7 +225,6 @@ pub fn mint_batch( }; let token_id = mint( - default_token_address, *mint_param.game_address, *mint_param.player_name, *mint_param.settings_id, diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index c17f1994..53f6b50b 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -6,8 +6,6 @@ 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_legacy::interface::IMINIGAME_TOKEN_LEGACY_ID; - use game_components_interfaces::token::core::IMINIGAME_TOKEN_ID; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use openzeppelin_introspection::src5::SRC5Component; @@ -23,7 +21,6 @@ pub mod MetagameComponent { #[storage] pub struct Storage { context_address: ContractAddress, - default_token_address: ContractAddress, } #[embeddable_as(MetagameImpl)] @@ -36,10 +33,6 @@ pub mod MetagameComponent { fn context_address(self: @ComponentState) -> ContractAddress { self.context_address.read() } - - fn default_token_address(self: @ComponentState) -> ContractAddress { - self.default_token_address.read() - } } #[generate_trait] @@ -50,9 +43,7 @@ pub mod MetagameComponent { +Drop, > of InternalTrait { fn initializer( - ref self: ComponentState, - context_address: Option, - default_token_address: ContractAddress, + ref self: ComponentState, context_address: Option, ) { self.register_src5_interfaces(); match context_address { @@ -69,16 +60,6 @@ pub mod MetagameComponent { }, Option::None => {}, } - assert!(!default_token_address.is_zero(), "Metagame: Default token address is zero"); - // Either token generation is a valid default: a legacy - // (registry-backed) token, or a self-bound standard token. - let minigame_dispatcher = ISRC5Dispatcher { contract_address: default_token_address }; - assert!( - minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_LEGACY_ID) - || minigame_dispatcher.supports_interface(IMINIGAME_TOKEN_ID), - "Metagame: Default token contract supports neither IMinigameToken nor IMinigameTokenLegacy", - ); - self.default_token_address.write(default_token_address); } fn register_src5_interfaces(ref self: ComponentState) { @@ -94,7 +75,7 @@ pub mod MetagameComponent { fn mint( ref self: ComponentState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -111,7 +92,6 @@ pub mod MetagameComponent { metadata: u16, ) -> felt252 { libs::mint( - self.default_token_address.read(), game_address, player_name, settings_id, @@ -133,7 +113,7 @@ 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 diff --git a/packages/embeddable_game_standard/src/metagame/structs.cairo b/packages/embeddable_game_standard/src/metagame/structs.cairo index 2bf8fe00..219ae0d4 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, 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..3fe4c951 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()); @@ -507,8 +506,8 @@ mod MockCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, default_token_address); - self.callback.initializer(); + self.metagame.initializer(context_address); + self.callback.initializer(default_token_address); } // View functions for test assertions @@ -596,7 +595,21 @@ mod MockEmptyCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, default_token_address); - self.callback.initializer(); + self.metagame.initializer(context_address); + 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 393f5626..c2f09e70 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 @@ -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, @@ -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), @@ -289,7 +289,7 @@ mod MockMetagameFuzz { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); } // Expose mint function for testing @@ -297,7 +297,7 @@ mod MockMetagameFuzz { impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, 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 d01c4d1f..0b8d869f 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -61,12 +61,12 @@ fn sample_context() -> GameContextDetails { // 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, @@ -117,12 +117,12 @@ fn test_mint_default_token_with_player_name() { // 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), @@ -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), @@ -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, @@ -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), @@ -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), @@ -455,9 +453,10 @@ fn test_mint_instant_game() { #[test] fn test_mint_batch_empty_array() { let token_address = deploy_mock_minigame_token(); + let game_address = deploy_mock_minigame(token_address); 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 +465,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 +487,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 +497,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 +518,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 +535,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 +553,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 +565,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 +586,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,7 +604,7 @@ 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 = IMinigameTokenLegacyDispatcher { contract_address: token_address }; @@ -619,11 +621,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 +644,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 +653,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 +675,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 +689,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, @@ -717,10 +721,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 +749,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, @@ -1415,10 +1419,11 @@ fn test_assert_game_registered_fails_for_unregistered() { fn test_mint_batch_mixed_game_addresses() { let token_address = deploy_mock_minigame_token(); let game_address = deploy_mock_minigame(token_address); + let game_address = deploy_mock_minigame(token_address); let mints = array![ MintMetagameParams { - game_address: Option::Some(game_address), + game_address: game_address, player_name: Option::Some('WithGame'), settings_id: Option::None, start: Option::None, @@ -1435,7 +1440,7 @@ fn test_mint_batch_mixed_game_addresses() { metadata: 0, }, MintMetagameParams { - game_address: Option::None, + game_address, player_name: Option::Some('NoGame'), settings_id: Option::None, start: Option::None, @@ -1453,7 +1458,7 @@ 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"); @@ -1469,11 +1474,12 @@ fn test_mint_batch_mixed_game_addresses() { #[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, @@ -1490,7 +1496,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, @@ -1507,7 +1513,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, @@ -1525,7 +1531,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"); @@ -1537,10 +1543,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, @@ -1557,7 +1564,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, @@ -1575,7 +1582,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"); } @@ -1583,6 +1590,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; @@ -1593,7 +1601,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, @@ -1613,7 +1621,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"); @@ -1995,8 +2003,7 @@ mod standard_token_paths { let game = deploy_standard_game(ALICE()); let token_id = libs::mint( - game, // default token (unused on this branch) - Option::Some(game), + game, Option::Some('player'), Option::None, Option::None, @@ -2037,7 +2044,6 @@ mod standard_token_paths { Option::None, Option::None, Option::None, - Option::None, BOB(), false, false, @@ -2056,7 +2062,6 @@ mod standard_token_paths { let game = deploy_standard_game(ALICE()); libs::mint( game, - Option::Some(game), Option::None, Option::None, Option::None, @@ -2080,7 +2085,6 @@ mod standard_token_paths { let game = deploy_standard_game(ALICE()); libs::mint( game, - Option::Some(game), Option::None, Option::None, Option::None, @@ -2115,7 +2119,9 @@ mod standard_token_paths { #[test] fn test_get_game_creator_address_is_the_declared_payee() { let game = deploy_standard_game(ALICE()); - assert!(libs::get_game_creator_address(game) == ALICE(), "payee is not the declared creator"); + assert!( + libs::get_game_creator_address(game) == ALICE(), "payee is not the declared creator", + ); } } @@ -2134,8 +2140,8 @@ mod legacy_single_game_token { use game_components_testing::constants::{ALICE, BOB}; use snforge_std::mock_call; use starknet::ContractAddress; - use super::{deploy_mock_minigame_for_registry, deploy_mock_token_with_registry}; 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() { 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 16f75bc2..f310a476 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 @@ -10,7 +10,7 @@ use crate::metagame::interface::{IMETAGAME_ID, IMetagameDispatcher, IMetagameDis trait IMockMetagame { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -52,7 +52,6 @@ fn test_initialization_with_both_addresses() { 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 @@ -81,7 +80,6 @@ fn test_initialization_with_token_only() { 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 @@ -89,30 +87,6 @@ fn test_initialization_with_token_only() { 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() { @@ -172,13 +146,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 +192,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 +203,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 +244,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 +263,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 +299,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 +315,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 +350,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 +392,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 +403,7 @@ fn test_mint_with_instant_game() { let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::Some(timestamp), // start @@ -474,7 +466,7 @@ mod MockMetagameContract { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); } // Expose mint function for testing @@ -482,7 +474,7 @@ mod MockMetagameContract { impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -988,6 +980,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 +990,7 @@ fn test_mint_with_renderer_address() { let token_id = dispatcher .mint( - Option::None, + game_address, Option::None, Option::None, Option::None, @@ -1027,13 +1022,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 +1063,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 +1091,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 +1110,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 +1144,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 +1170,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, @@ -1236,7 +1240,7 @@ mod MockMetagameContractForErrors { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); } } @@ -1245,7 +1249,7 @@ mod MockMetagameContractForErrors { trait IMockMetagameWithBatch { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -1309,14 +1313,14 @@ mod MockMetagameContractWithBatch { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_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, 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 1bb4436e..e47a1521 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 @@ -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), @@ -255,7 +255,7 @@ mod MockMetagameWithContext { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); } // Expose mint function for testing @@ -263,7 +263,7 @@ mod MockMetagameWithContext { impl MockMetagameImpl of super::IMockMetagame { fn mint( ref self: ContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, @@ -307,7 +307,7 @@ mod MockMetagameWithContext { trait IMockMetagame { fn mint( ref self: TContractState, - game_address: Option, + game_address: ContractAddress, player_name: Option, settings_id: Option, start: Option, diff --git a/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo index cee1e84c..150bb5e0 100644 --- a/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_context.cairo @@ -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_legacy/tests/test_context_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo index dfdda9a7..c160eaa7 100644 --- a/packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_context_coverage.cairo @@ -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_legacy/tests/test_examples_coverage.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo index e34f02ae..ebb8da47 100644 --- a/packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_examples_coverage.cairo @@ -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_legacy/tests/test_integration.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo index 00de4522..e8ce490e 100644 --- a/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo @@ -266,7 +266,7 @@ mod TokenMockMetagameWithContext { context_address: Option, minigame_token_address: ContractAddress, ) { - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); } } @@ -626,7 +626,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 +668,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 +707,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 +750,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/interfaces/src/metagame/core.cairo b/packages/interfaces/src/metagame/core.cairo index 4032f7a5..d5849b45 100644 --- a/packages/interfaces/src/metagame/core.cairo +++ b/packages/interfaces/src/metagame/core.cairo @@ -3,11 +3,15 @@ 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; +/// +/// `default_token_address` was removed: every game brings its own token, so a +/// metagame-wide default token is a registry-era concept with nothing to point +/// at. The id changed accordingly — consumers probing the old +/// `0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2` must +/// update. +pub const IMETAGAME_ID: felt252 = 0x1363c8de5144122290d663c4c7a10d09518fbe76475610a7027ea4770b9c179; #[starknet::interface] pub trait IMetagame { fn context_address(self: @TContractState) -> ContractAddress; - fn default_token_address(self: @TContractState) -> ContractAddress; } diff --git a/packages/test_common/src/mocks/metagame_mock.cairo b/packages/test_common/src/mocks/metagame_mock.cairo index c9aa15bd..73c0d391 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, @@ -170,7 +170,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, @@ -256,7 +256,7 @@ pub mod metagame_mock { supports_context: bool, ) { // Initialize the metagame component - self.metagame.initializer(context_address, minigame_token_address); + self.metagame.initializer(context_address); // Initialize local storage self.token_counter.write(0); @@ -267,7 +267,7 @@ pub mod metagame_mock { } // Initialize callback component (registers SRC5 interface) - self.callback.initializer(); + self.callback.initializer(minigame_token_address); } } } From c4934060ee2f15262027d3b8b1da07aeb142fdf1 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:42:11 -0700 Subject: [PATCH 18/33] =?UTF-8?q?refactor(metagame)!:=20self-binding=20com?= =?UTF-8?q?ponent=20=E2=80=94=20no=20context=20address,=20no=20ABI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors `MinigameTokenComponent`: the embedding contract IS the metagame, so it holds no addresses at all. `context_address` had no consumers. A metagame that provides context embeds `ContextComponent` itself, registering IMETAGAME_CONTEXT_ID on its own address — nothing ever resolved a provider through a stored address, because the legacy token takes context as a mint parameter and dispatches to `ContextOpt::on_context_set`. BREAKING: * `MetagameComponent` has empty storage and no `initializer`. Neither address it used to hold exists, so there was nothing left to initialize. * `IMetagame` and `IMETAGAME_ID` are REMOVED, not renumbered. With both views gone the trait had no methods, and an SRC5 id cannot be derived from an empty selector set. Nothing probed the id: the component registered it and two tests asserted the registration, with no production consumer. 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 is now internal helpers over `metagame::metagame` (`libs`), each branching on SRC5 to serve both token generations. Removes the four tests covering the deleted views (T001.1, T001.2 and the two context_address view tests), leaving a note in their place. Tests: full package 1193 passed / 0 failed (1197 minus those four). Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 5 +- .../src/metagame/AGENTS.md | 42 +++--- .../src/metagame/README.md | 13 +- .../src/metagame/interface.cairo | 6 +- .../src/metagame/metagame_component.cairo | 66 ++------- .../src/metagame/tests/test_callback.cairo | 6 - .../tests/test_fuzz_mint_parameters.cairo | 6 +- .../tests/test_metagame_component.cairo | 125 +----------------- .../metagame/tests/test_tournament_flow.cairo | 6 +- .../src/token_legacy/tests/setup.cairo | 10 +- .../token_legacy/tests/test_integration.cairo | 6 +- packages/interfaces/src/AGENTS.md | 1 - packages/interfaces/src/README.md | 1 - packages/interfaces/src/lib.cairo | 11 +- packages/interfaces/src/metagame.cairo | 2 - packages/interfaces/src/metagame/core.cairo | 36 ++--- .../test_common/src/mocks/metagame_mock.cairo | 3 - 17 files changed, 85 insertions(+), 260 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7cd1fb20..5e833299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,7 +125,10 @@ Metagame ──→ MinigameTokenLegacy (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. +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 diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index b7320f8c..9d6f5392 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -6,32 +6,36 @@ High-level game management component for token delegation and minting coordinati ```cairo #[storage] -pub struct Storage { - context_address: ContractAddress, // Optional IMetagameContext -} +pub struct Storage {} // Self-bound: no addresses to hold ``` -There is no metagame-wide default token: every game brings its own, so the -token is resolved from `game_address` on each mint. +The component is **self-binding**, like `MinigameTokenComponent`: the embedding +contract IS the metagame. It stores no addresses. -## Interfaces +- **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. -### IMetagame (Read-only) +## Interfaces -| Method | Returns | Description | -|--------|---------|-------------| -| `context_address()` | `ContractAddress` | Optional context contract (tournaments/events) | +### IMetagame — REMOVED -**Interface ID**: `0x1363c8de5144122290d663c4c7a10d09518fbe76475610a7027ea4770b9c179` +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`). -Removing `default_token_address()` changed the id — consumers probing the -previous value must update. +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)` | 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 | @@ -86,9 +90,9 @@ mod MyMetagame { ## Relationships ``` -Metagame - |-- context_address --------> IMetagameContext (OPTIONAL) - `-- per-mint: game_address --> IMinigame.token_address() --> the game's token +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 @@ -98,7 +102,9 @@ a legacy registry-backed token. This applies to `assert_game_registered`, ## Initialization Requirements -- `context_address` (if provided) MUST support `IMETAGAME_CONTEXT_ID`, validated via SRC5 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 diff --git a/packages/embeddable_game_standard/src/metagame/README.md b/packages/embeddable_game_standard/src/metagame/README.md index ba30479c..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 | 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_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 53f6b50b..46dc4512 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -1,72 +1,30 @@ +/// 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 openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; - 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; + /// Self-bound: no addresses to hold. #[storage] - pub struct Storage { - context_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() - } - } + 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, - ) { - 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 => {}, - } - } - - 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, ) { 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 3fe4c951..09bdf465 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_callback.cairo @@ -465,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)] @@ -506,7 +504,6 @@ mod MockCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address); self.callback.initializer(default_token_address); } @@ -561,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)] @@ -595,7 +590,6 @@ mod MockEmptyCallbackContract { context_address: Option, default_token_address: ContractAddress, ) { - self.metagame.initializer(context_address); self.callback.initializer(default_token_address); } } 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 c2f09e70..b7e9e075 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 @@ -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,9 +286,7 @@ mod MockMetagameFuzz { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] 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 f310a476..8077d6ff 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 @@ -3,7 +3,6 @@ use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTra 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] @@ -28,109 +27,9 @@ trait IMockMetagame { ) -> 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.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.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.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] @@ -435,8 +334,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)] @@ -465,9 +362,7 @@ mod MockMetagameContract { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] @@ -1209,8 +1104,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)] @@ -1239,9 +1132,7 @@ mod MockMetagameContractForErrors { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address); - } + ) {} } // Interface for batch testing @@ -1282,8 +1173,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)] @@ -1312,9 +1201,7 @@ mod MockMetagameContractWithBatch { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address); - } + ) {} #[abi(embed_v0)] impl MockMetagameWithBatchImpl of super::IMockMetagameWithBatch { 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 e47a1521..b4f6371d 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 @@ -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,9 +252,7 @@ mod MockMetagameWithContext { ref self: ContractState, context_address: Option, minigame_token_address: ContractAddress, - ) { - self.metagame.initializer(context_address); - } + ) {} // Expose mint function for testing #[abi(embed_v0)] diff --git a/packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/setup.cairo index 13817a66..71baa807 100644 --- a/packages/embeddable_game_standard/src/token_legacy/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; @@ -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_legacy/tests/test_integration.cairo b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo index e8ce490e..e50428de 100644 --- a/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo +++ b/packages/embeddable_game_standard/src/token_legacy/tests/test_integration.cairo @@ -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); - } + ) {} } // ================================================================================================ diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index 8e540e92..ea85a994 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -29,7 +29,6 @@ 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...; diff --git a/packages/interfaces/src/README.md b/packages/interfaces/src/README.md index 215509df..846276ca 100644 --- a/packages/interfaces/src/README.md +++ b/packages/interfaces/src/README.md @@ -28,7 +28,6 @@ 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...; diff --git a/packages/interfaces/src/lib.cairo b/packages/interfaces/src/lib.cairo index 4c23e23a..9bee7f06 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 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 d5849b45..4e8ab945 100644 --- a/packages/interfaces/src/metagame/core.cairo +++ b/packages/interfaces/src/metagame/core.cairo @@ -1,17 +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` was removed: every game brings its own token, so a -/// metagame-wide default token is a registry-era concept with nothing to point -/// at. The id changed accordingly — consumers probing the old -/// `0x7997c74299c045696726f0f7f0165f85817acbb0964e23ff77e11e34eff6f2` must -/// update. -pub const IMETAGAME_ID: felt252 = 0x1363c8de5144122290d663c4c7a10d09518fbe76475610a7027ea4770b9c179; - -#[starknet::interface] -pub trait IMetagame { - fn context_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/test_common/src/mocks/metagame_mock.cairo b/packages/test_common/src/mocks/metagame_mock.cairo index 73c0d391..c8552bb4 100644 --- a/packages/test_common/src/mocks/metagame_mock.cairo +++ b/packages/test_common/src/mocks/metagame_mock.cairo @@ -84,8 +84,6 @@ pub mod metagame_mock { } } - #[abi(embed_v0)] - impl MetagameImpl = MetagameComponent::MetagameImpl; impl MetagameInternalImpl = MetagameComponent::InternalImpl; impl ContextInternalImpl = ContextComponent::InternalImpl; @@ -256,7 +254,6 @@ pub mod metagame_mock { supports_context: bool, ) { // Initialize the metagame component - self.metagame.initializer(context_address); // Initialize local storage self.token_counter.write(0); From 767fd1a7f3e2a4720a314eee21b0771f49be0534 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:21:26 -0700 Subject: [PATCH 19/33] fix(metagame): clear test-compilation warnings from the metagame refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI fails the build on any test-compilation diagnostic; my local runs were filtered to `Tests:|FAIL`, which hid them. All five came from the preceding two commits: * `test_mint_batch_empty_array` bound a game it never used — an empty batch needs no game at all. * `test_mint_batch_mixed_game_addresses` had a duplicated binding that shadowed the first, so both entries minted against the SAME game and the test no longer tested anything mixed. It now deploys game_a and game_b and asserts each token carries its own game address; the leftover 'NoGame' assertion belonged to the deleted default-token arm. * `Zero` and `ISRC5Dispatcher`/`Trait` were orphaned in test_metagame_component when the four IMetagame view tests were removed. Also refreshes comments still describing the removed default-token path, and renames `test_mint_defaults_to_standard_token` (a duplicate after the change) to `test_mint_standard_token_minimal`. Verified with CI's own gate — `grep -qE '^ --> .*\.cairo:[0-9]+:[0-9]+'` over the raw test log — across all five packages: no diagnostics anywhere. embeddable_game_standard 1193 passed / 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/tests/test_libs.cairo | 40 ++++++++++--------- .../tests/test_metagame_component.cairo | 2 - 2 files changed, 22 insertions(+), 20 deletions(-) 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 0b8d869f..4e104df7 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -56,7 +56,7 @@ 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 @@ -452,9 +452,6 @@ 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 game_address = deploy_mock_minigame(token_address); - let mints: Array = array![]; let token_ids = libs::mint_batch(mints); @@ -1418,13 +1415,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_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: 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, @@ -1440,8 +1437,8 @@ fn test_mint_batch_mixed_game_addresses() { metadata: 0, }, MintMetagameParams { - game_address, - player_name: Option::Some('NoGame'), + game_address: game_b, + player_name: Option::Some('GameB'), settings_id: Option::None, start: Option::None, end: Option::None, @@ -1463,11 +1460,19 @@ fn test_mint_batch_mixed_game_addresses() { assert!(token_ids.len() == 2, "Should return 2 token IDs"); let token_dispatcher = IMinigameTokenLegacyDispatcher { 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"); + // 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 @@ -2027,10 +2032,9 @@ mod standard_token_paths { assert!(token.player_name(token_id) == 'player', "player name not stored"); } - /// With no game address the default token is used directly — a standard - /// token has no blank-game concept, the mint belongs to its own game. + /// Minimal mint through a standard game — every optional param None. #[test] - fn test_mint_defaults_to_standard_token() { + fn test_mint_standard_token_minimal() { let game = deploy_standard_game(ALICE()); let token_id = libs::mint( 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 8077d6ff..8a43bcf9 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,5 +1,3 @@ -use core::num::traits::Zero; -use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; use starknet::ContractAddress; use crate::metagame::extensions::context::structs::GameContextDetails; From efb4397edea29c4c680e0101f27437862fcc4a1e Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:42:31 -0700 Subject: [PATCH 20/33] fix(metagame): reject a failed game-fee transfer instead of reporting it paid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pay_game_fee` ignored the bool returned by `IERC20.transfer`. An ERC20 that signals failure by returning false rather than reverting would leave the component returning a non-zero `fee_amount`, so callers would proceed as if the game creator had been paid. Pre-existing, but the new standard-token creator-fee path inherits it. The lib cannot defend against this from the outside — there is no catchable external call here — so the assert belongs at the transfer site. Adds the first `pay_game_fee` tests (there were none): the false-return rejection, the happy path returning 5% of revenue at the default 500 bps, and the zero-revenue short circuit, via a MockFeePayer exposing the component method. Reported by Codex review. Full package: 1196 passed / 0 failed, no compilation diagnostics. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/metagame_component.cairo | 7 +- .../tests/test_metagame_component.cairo | 115 ++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 46dc4512..74ad879d 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -93,9 +93,12 @@ pub mod MetagameComponent { // current owner. let recipient = libs::get_game_creator_address(game_address); - // 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/tests/test_metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_metagame_component.cairo index 8a43bcf9..5530ea8f 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,3 +1,4 @@ +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; @@ -1249,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 (creator 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 creator = fee 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"); +} From 4e4f8d236409c7b4838a57ef533026f35a798963 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:19:26 -0700 Subject: [PATCH 21/33] fix(metagame)!: enforce the self-bound pairing on every standard-token path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The standard-token branches I added trusted `game_address.token_address()` without checking that the game IS its token. The legacy paths were implicitly protected — they resolve through the registry, which rejects unregistered games — but the standard paths had no equivalent gate. A standard token is self-bound, so `token_address() == game_address` is its registration check. `assert_game_registered` already applied it; `mint`, `get_game_fee_info` and `get_game_creator_address` did not. Impact: an attacker deploys a standard token naming themselves as creator with `fee_numerator` at 10000, plus a trivial contract whose `token_address()` returns it. A metagame calling `pay_game_fee` with that address transfers 100% of revenue to the attacker. The same gap let a hostile contract have a metagame mint on a standard token it does not own, poisoning `minted_by`. All three paths now call a shared `assert_self_bound` before trusting the token. Adds four regression tests: a hostile game blocked on each path, plus a legitimate self-bound game confirmed to still pass all three — three should_panic tests would also pass if the guard were simply too broad. Reported by Codex review. Full package: 1200 passed / 0 failed, no compilation diagnostics. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/metagame.cairo | 15 ++- .../src/metagame/tests/test_libs.cairo | 92 +++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 9ac30d8d..28735d31 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -60,6 +60,14 @@ pub fn assert_game_registered(game_address: ContractAddress) { assert!(game_exists, "Game is not registered"); } +/// 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 creator. +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 { @@ -150,8 +158,9 @@ pub fn mint( 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. + // to mint for them too — under the same pairing check. if is_standard_token(minigame_token_address) { + assert_self_bound(minigame_token_address, game_address); return mint_standard_token( minigame_token_address, player_name, @@ -268,6 +277,8 @@ 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(); if supports_creator_surface(minigame_token_address) { + // The creator surface belongs to the self-bound standard token. + assert_self_bound(minigame_token_address, game_address); let info = IMinigameTokenCreatorDispatcher { contract_address: minigame_token_address } .game_creator_info(); return GameFeeInfo { license: info.license, fee_numerator: info.fee_numerator }; @@ -299,6 +310,8 @@ pub fn get_game_creator_address(game_address: ContractAddress) -> ContractAddres let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; let token_address = minigame_dispatcher.token_address(); if supports_creator_surface(token_address) { + // Same pairing check: a hostile game must not redirect the payee. + assert_self_bound(token_address, game_address); return IMinigameTokenCreatorDispatcher { contract_address: token_address } .game_creator_address(); } 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 4e104df7..9b917cf0 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -2182,3 +2182,95 @@ mod legacy_single_game_token { 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 creator — 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_creator: 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_creator.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 creator. + #[test] + #[should_panic(expected: "Game is not registered")] + fn test_get_game_creator_address_rejects_foreign_standard_token() { + let victim = deploy_standard_game(ALICE()); + let hostile = hostile_game_pointing_at(victim); + libs::get_game_creator_address(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_creator_address(game) == ALICE(), "payee should be the creator"); + assert!(libs::get_game_fee_info(game).fee_numerator == 500, "default fee is 500 bps"); + } +} From c92e3c4c91158d4983f32faa93edc63afe956e02 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:11:11 -0700 Subject: [PATCH 22/33] feat(tokenomics): opt-in strict per-token config mode for BuybackComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buy_back is permissionless and _get_effective_config falls back to the global config for any token without a per-token override — so every token is buyable-back by default, with no way for a deployment to say "only tokens I have explicitly configured". Ekubo's revenue_buybacks expresses this as an Option default; that exact shape does not transfer here because sweep_buy_token_to_treasury reads buy_token/treasury identity off the global config, which must stay mandatory. Only the trading POLICY is gated: - Buyback_require_token_config storage flag (default false — no-op for every existing consumer; purely opt-in strictness) - _get_effective_config asserts 'No config for token' in the fallback arm when the flag is on - IBuybackAdmin::set_require_token_config + RequireTokenConfigUpdated event; owner-gated in AutonomousBuyback (and the test mock) Rationale (Budokan): protocol-fee revenue arrives in whatever token a tournament host charged; with the flag on, governance decides which of those are tradeable rather than anyone opening TWAMM orders into whatever pool happens to exist. Replaces a rejected allowlist-wrapper design in token_buybacks — config-level gating belongs in the component. tokenomics 221/221 (3 new), presets 187/187. Co-Authored-By: Claude Fable 5 --- .../src/tokenomics/buyback/buyback.cairo | 26 ++++++++ .../economy/src/tokenomics/constants.cairo | 1 + .../tests/mocks/test_autonomous_buyback.cairo | 5 ++ .../src/tokenomics/tests/test_buyback.cairo | 61 +++++++++++++++++++ .../interfaces/src/tokenomics/buyback.cairo | 6 ++ packages/presets/src/autonomous_buyback.cairo | 7 +++ 6 files changed, 106 insertions(+) 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/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/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( From 103b40534e5b40b670338247ef41dc582aa9d4d5 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:46:14 -0700 Subject: [PATCH 23/33] docs(tokenomics): document set_require_token_config in the admin table Co-Authored-By: Claude Fable 5 --- packages/economy/src/tokenomics/AGENTS.md | 1 + 1 file changed, 1 insertion(+) 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 From 2c13741cc0bd758806cb6a78b946b60f9e87a811 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:52:54 -0700 Subject: [PATCH 24/33] =?UTF-8?q?feat(token):=20MinigameTokenMixinImpl=20?= =?UTF-8?q?=E2=80=94=20one=20embed=20for=20the=20full=20standard=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initializer registers IMINIGAME_TOKEN_ID, IMINIGAME_TOKEN_MINTER_ID and IMINIGAME_TOKEN_CREATOR_ID unconditionally, but MinigameTokenImpl / MinterImpl / CreatorImpl were separate embeds — a contract wiring only some of them would advertise SRC5 surfaces it does not expose, and consumers that probe-then-dispatch (e.g. metagame's get_game_fee_info) would revert on the missing entrypoint. Fix, ERC20MixinImpl-style: new MinigameTokenABI trait (interfaces token/core) combining all 22 entrypoints, and MinigameTokenMixinImpl forwarding to the three inner impls — one embed, honest SRC5 by construction. NOT used for id derivation; the three ids are unchanged. Separate impls stay exported for contracts that wire them individually (SDM GameCore does today — adoption optional). StandardGameMock switches to the mixin; all existing dispatcher-based tests exercise it unchanged. token 54 / metagame 105 / presets 187. Co-Authored-By: Claude Fable 5 --- .../src/token/AGENTS.md | 12 +- .../src/token/minigame_token_component.cairo | 167 +++++++++++++++++- packages/interfaces/src/token.cairo | 1 + packages/interfaces/src/token/core.cairo | 70 ++++++++ .../src/mocks/standard_game_mock.cairo | 13 +- 5 files changed, 252 insertions(+), 11 deletions(-) diff --git a/packages/embeddable_game_standard/src/token/AGENTS.md b/packages/embeddable_game_standard/src/token/AGENTS.md index 8ba2aae9..35b6e57a 100644 --- a/packages/embeddable_game_standard/src/token/AGENTS.md +++ b/packages/embeddable_game_standard/src/token/AGENTS.md @@ -111,9 +111,17 @@ 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`, `CreatorImpl`) remain exported; a contract wiring them +individually MUST embed all three or its SRC5 answers lie. + Requires: `ERC721Component`, `SRC5Component`, `OwnableComponent` (hard -`HasComponent` bound on `CreatorImpl` — the owner administers the creator -surface), and an `ERC721HooksTrait` +`HasComponent` bound on `CreatorImpl` and the mixin — the owner administers +the creator 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 diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo index d05f432f..5e35077b 100644 --- a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -49,7 +49,9 @@ 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}; + use game_components_interfaces::token::core::{ + IMINIGAME_TOKEN_ID, IMinigameToken, MinigameTokenABI, + }; use game_components_interfaces::token::creator::{ DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameCreatorInfo, IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreator, default_license, @@ -509,6 +511,169 @@ pub mod MinigameTokenComponent { } } + /// One-embed mixin over the full standard surface (token + absorbed + /// minter + creator), mirroring OZ's ERC20MixinImpl pattern. The + /// initializer registers all three SRC5 ids unconditionally, so embedding + /// this single impl — rather than MinigameTokenImpl / MinterImpl / + /// CreatorImpl 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) + } + + // IMinigameTokenCreator + fn game_creator_info(self: @ComponentState) -> GameCreatorInfo { + Creator::game_creator_info(self) + } + fn game_creator_address(self: @ComponentState) -> ContractAddress { + Creator::game_creator_address(self) + } + fn set_game_creator_address( + ref self: ComponentState, new_creator: ContractAddress, + ) { + Creator::set_game_creator_address(ref self, new_creator) + } + fn set_game_fee( + ref self: ComponentState, license: ByteArray, fee_numerator: u16, + ) { + Creator::set_game_fee(ref self, license, fee_numerator) + } + } + #[generate_trait] pub impl InternalImpl< TContractState, diff --git a/packages/interfaces/src/token.cairo b/packages/interfaces/src/token.cairo index 8b9b6fdf..0c9c9669 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -14,6 +14,7 @@ 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 creator::{ IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreator, IMinigameTokenCreatorDispatcher, diff --git a/packages/interfaces/src/token/core.cairo b/packages/interfaces/src/token/core.cairo index 14e0f2a4..5164324f 100644 --- a/packages/interfaces/src/token/core.cairo +++ b/packages/interfaces/src/token/core.cairo @@ -145,3 +145,73 @@ pub trait IMinigameToken { /// Owner-gated rename; emits `MetadataUpdate`. fn update_player_name(ref self: TState, token_id: felt252, name: felt252); } + +/// Combined mixin ABI: the full external surface of the standard token — +/// `IMinigameToken` + the absorbed minter (`IMinigameTokenMinter`) + the +/// creator surface (`IMinigameTokenCreator`) — 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_CREATOR_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); + + // 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; + + // IMinigameTokenCreator (creator payout identity) + fn game_creator_info(self: @TState) -> crate::structs::token::GameCreatorInfo; + fn game_creator_address(self: @TState) -> ContractAddress; + fn set_game_creator_address(ref self: TState, new_creator: ContractAddress); + fn set_game_fee(ref self: TState, license: ByteArray, fee_numerator: u16); +} diff --git a/packages/test_common/src/mocks/standard_game_mock.cairo b/packages/test_common/src/mocks/standard_game_mock.cairo index 4d191558..0a49a1b2 100644 --- a/packages/test_common/src/mocks/standard_game_mock.cairo +++ b/packages/test_common/src/mocks/standard_game_mock.cairo @@ -112,15 +112,12 @@ pub mod StandardGameMock { impl ERC721MetadataImpl = ERC721Component::ERC721MetadataImpl; #[abi(embed_v0)] impl SRC5Impl = SRC5Component::SRC5Impl; + // One embed for the full standard surface (token + absorbed minter + + // creator) — the mixin keeps the SRC5 ids registered by the initializer + // honest by construction. #[abi(embed_v0)] - impl MinigameTokenImpl = - MinigameTokenComponent::MinigameTokenImpl; - // The minter registry and creator surface are absorbed into the token - // component — plain embeds, no separate components. - #[abi(embed_v0)] - impl MinterImpl = MinigameTokenComponent::MinterImpl; - #[abi(embed_v0)] - impl CreatorImpl = MinigameTokenComponent::CreatorImpl; + impl MinigameTokenMixinImpl = + MinigameTokenComponent::MinigameTokenMixinImpl; #[abi(embed_v0)] impl OwnableImpl = OwnableComponent::OwnableImpl; From b7fb0e7cc628c6390a859738a844386733d78589 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:11:52 -0700 Subject: [PATCH 25/33] docs(token): state the initializer's all-three-surfaces invariant for embedders The SRC5-honesty gap is closed by construction only for mixin-wired contracts; individually-wired ones are honest by convention. Put the invariant where an embedder actually looks. Co-Authored-By: Claude Fable 5 --- .../src/token/minigame_token_component.cairo | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo index 5e35077b..ed58fa0e 100644 --- a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -712,6 +712,14 @@ pub mod MinigameTokenComponent { /// 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` + `CreatorImpl` 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. + /// /// `game_creator` 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 — From de87644d9c6587046e53bce7360b8a6c867f8169 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:13:44 -0700 Subject: [PATCH 26/33] docs(token): the partial-wiring revert triggers at first fee claim, not deploy Co-Authored-By: Claude Fable 5 --- .../src/token/minigame_token_component.cairo | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo index ed58fa0e..c141d68a 100644 --- a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -718,7 +718,10 @@ pub mod MinigameTokenComponent { /// `MinigameTokenImpl` + `MinterImpl` + `CreatorImpl` 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. + /// (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_creator` must be non-zero (it is the monetization payee); /// `license`/`fee_numerator` default to the ecosystem terms From 198267c98d57dff48375bb30f4dffe60e7b1f2ce Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:55:12 -0700 Subject: [PATCH 27/33] =?UTF-8?q?feat(metagame):=20mint=5Fbatch=5Frecipien?= =?UTF-8?q?ts=20passthrough=20=E2=80=94=20one=20dispatch=20per=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mint_batch` loops over `mint`: one cross-contract call per token, with the `context` struct (which holds an Array) re-serialised each time. That made the component unadoptable for budokan v2, whose tournament entry mints every entrant through the token's own `mint_batch_recipients` in a single call — adopting would have turned 1 dispatch into N on its hottest path. Adds `libs::mint_batch_recipients` and the matching component method, routing one dispatch to whichever generation the game's token is. `mint_batch` stays: it serves heterogeneous batches where each entry names a different game. `metadata` is `u128`, not `u16`, because that is what the consumer actually passes — budokan widened metadata_value to u128 (their 67875f1), so a u16 passthrough would have compiled and been useless. The legacy token's field is u16, so the legacy path asserts the value fits instead of truncating silently. The standard path takes the same `assert_self_bound` gate as every other standard-token path, and rejects renderer/skills loudly rather than dropping them. Tests (10, own file): recipient ordering and per-recipient counts, distinct ids across the global salt counter, the wide-metadata round trip verified through `mint_metadata`, hostile-game rejection, renderer/skills rejection, and the legacy path both accepting a renderer and rejecting over-wide metadata. `mint_batch_recipients` was `panic!("not implemented")` in both legacy test mocks — implemented for real so the legacy branch is exercised rather than stubbed. Also refreshes the module AGENTS.md: the embedding example still showed the removed `MetagameImpl` ABI, the InternalTrait table was missing several methods, and MintMetagameParams was missing four fields. Full package: 1210 passed / 0 failed, no compilation diagnostics, fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/AGENTS.md | 33 +- .../src/metagame/metagame.cairo | 85 +++++ .../src/metagame/metagame_component.cairo | 42 +++ .../src/metagame/tests.cairo | 1 + .../tests/test_batch_recipients.cairo | 344 ++++++++++++++++++ .../src/metagame/tests/test_libs.cairo | 46 ++- 6 files changed, 545 insertions(+), 6 deletions(-) create mode 100644 packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index 9d6f5392..2b224bab 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -36,9 +36,23 @@ legacy callback receiver. The component now exposes internals only. | Method | Description | |--------|-------------| -| `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)` / `pay_game_fee(...)` | Resolve fee terms / pay the game creator | + +**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. + +`mint_batch_recipients` takes `metadata: u128` to reach the standard token's +65-bit field. The legacy token's field is `u16`, so the legacy path asserts the +value fits rather than truncating it. (`mint` still takes `metadata: u16` — a +known inconsistency, widening it is a further breaking change.) ## Extensions @@ -82,11 +96,15 @@ 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 ``` @@ -126,7 +144,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: u16, } ``` + +`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/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 28735d31..4873218d 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -9,6 +9,7 @@ use game_components_embeddable_game_standard::registry::interface::{ 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, }; @@ -202,6 +203,90 @@ pub fn mint( ) } +/// 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 +/// * `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` - 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(); + + if is_standard_token(minigame_token_address) { + assert_self_bound(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, + ); + } + + // 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. diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 74ad879d..6fdf41a8 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -12,6 +12,7 @@ #[starknet::component] pub mod MetagameComponent { use game_components_embeddable_game_standard::metagame::extensions::context::structs::GameContextDetails; + use game_components_interfaces::structs::token::MintBatchRecipient; use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; use starknet::contract_address::ContractAddress; use crate::metagame::metagame as libs; @@ -74,6 +75,47 @@ pub mod MetagameComponent { libs::mint_batch(mints) } + /// 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, + ) + } + /// Reads fee from registry, calculates amount, transfers via ERC20 /// to the game's creator token owner. Returns fee amount (0 if no fee). fn pay_game_fee( 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..80224b4b --- /dev/null +++ b/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo @@ -0,0 +1,344 @@ +// ============================================================================= +// 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, + ); +} + +/// 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_libs.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo index 9b917cf0..0c694fbc 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -1088,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) {} @@ -1932,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) {} From 7b1ddf18cd633d821d8c773f4320f023cb572064 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:34:52 -0700 Subject: [PATCH 28/33] feat(metagame)!: widen mint metadata to u128, matching the batch path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mint` took `metadata: u16` while `mint_batch_recipients` took `u128`, so the two paths disagreed about how much metadata a caller could carry. Budokan threads a u128 metadata_value through all three of its entrypoints; adopting `mint` as-is would have narrowed its single-mint path to 16 bits while its batch path kept 128 — a regression on work that had just landed. BREAKING: `libs::mint`, `MetagameComponent::mint` and `MintMetagameParams.metadata` all take `u128`. The legacy token's field is still `u16`, so the legacy branch narrows with the same `try_into().expect('Metagame: metadata exceeds u16')` the batch path already used — both paths now reject identically rather than one truncating. Only the metagame surfaces widened. Most `metadata: u16` occurrences in these files belong to legacy TOKEN mocks implementing `IMinigameTokenLegacy`, whose field genuinely is u16; widening those would have made the mocks lie about the interface they implement. `IMinigame::mint_game` also keeps u16 — the minigame helper layer is legacy-only (MinigameComponent asserts IMINIGAME_TOKEN_LEGACY_ID), so u16 is correct there. Two tests that discriminate rather than decorate: `test_mint_carries_wide_metadata` round-trips 0x100000000 through `mint` and reads it back via `mint_metadata` (would not compile under u16, would fail under silent truncation), and `test_mint_rejects_wide_metadata_on_legacy_token` proves the single path narrows exactly as the batch path does. Raised by the budokan session, which hit it while evaluating adoption. Full package: 1212 passed / 0 failed, no compilation diagnostics, fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/AGENTS.md | 11 +++---- .../src/metagame/metagame.cairo | 10 ++++--- .../src/metagame/metagame_component.cairo | 2 +- .../src/metagame/structs.cairo | 5 +++- .../tests/test_batch_recipients.cairo | 27 +++++++++++++++++ .../tests/test_fuzz_mint_parameters.cairo | 4 +-- .../src/metagame/tests/test_libs.cairo | 30 +++++++++++++++++++ .../tests/test_metagame_component.cairo | 8 ++--- .../metagame/tests/test_tournament_flow.cairo | 4 +-- .../test_common/src/mocks/metagame_mock.cairo | 4 +-- 10 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index 2b224bab..3c16dd68 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -49,10 +49,11 @@ 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. -`mint_batch_recipients` takes `metadata: u128` to reach the standard token's -65-bit field. The legacy token's field is `u16`, so the legacy path asserts the -value fits rather than truncating it. (`mint` still takes `metadata: u16` — a -known inconsistency, widening it is a further breaking change.) +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 @@ -149,7 +150,7 @@ pub struct MintMetagameParams { pub soulbound: bool, pub paymaster: bool, pub salt: u16, - pub metadata: u16, + pub metadata: u128, } ``` diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 4873218d..051d68f8 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -96,7 +96,7 @@ fn mint_standard_token( soulbound: bool, paymaster: bool, salt: u16, - metadata: 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"); @@ -113,7 +113,7 @@ fn mint_standard_token( soulbound, paymaster, salt, - metadata.into(), + metadata, ) } @@ -153,7 +153,7 @@ pub fn mint( soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; let minigame_token_address = minigame_dispatcher.token_address(); @@ -180,6 +180,8 @@ pub fn mint( metadata, ); } + // 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, }; @@ -199,7 +201,7 @@ pub fn mint( soulbound, paymaster, salt, - metadata, + legacy_metadata, ) } diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 6fdf41a8..adfa7624 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -48,7 +48,7 @@ pub mod MetagameComponent { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { libs::mint( game_address, diff --git a/packages/embeddable_game_standard/src/metagame/structs.cairo b/packages/embeddable_game_standard/src/metagame/structs.cairo index 219ae0d4..497e4630 100644 --- a/packages/embeddable_game_standard/src/metagame/structs.cairo +++ b/packages/embeddable_game_standard/src/metagame/structs.cairo @@ -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/test_batch_recipients.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo index 80224b4b..1fadd5b0 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_batch_recipients.cairo @@ -316,6 +316,33 @@ fn test_batch_recipients_rejects_wide_metadata_on_legacy_token() { ); } +/// 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() { 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 b7e9e075..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 @@ -24,7 +24,7 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -307,7 +307,7 @@ mod MockMetagameFuzz { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame 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 0c694fbc..3c6b1792 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -2074,6 +2074,36 @@ mod standard_token_paths { 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() { 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 5530ea8f..8bac0628 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 @@ -22,7 +22,7 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -382,7 +382,7 @@ mod MockMetagameContract { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -1153,7 +1153,7 @@ trait IMockMetagameWithBatch { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; fn mint_batch( @@ -1220,7 +1220,7 @@ mod MockMetagameContractWithBatch { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame 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 b4f6371d..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 @@ -273,7 +273,7 @@ mod MockMetagameWithContext { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252 { self .metagame @@ -317,7 +317,7 @@ trait IMockMetagame { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } diff --git a/packages/test_common/src/mocks/metagame_mock.cairo b/packages/test_common/src/mocks/metagame_mock.cairo index c8552bb4..566b04cb 100644 --- a/packages/test_common/src/mocks/metagame_mock.cairo +++ b/packages/test_common/src/mocks/metagame_mock.cairo @@ -17,7 +17,7 @@ pub trait IMetagameMock { soulbound: bool, paymaster: bool, salt: u16, - metadata: u16, + metadata: u128, ) -> felt252; } @@ -181,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(); From 9a0d5b9ec272993e4b25fbcac01bb96e1b278c2e Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:18:52 -0700 Subject: [PATCH 29/33] =?UTF-8?q?feat(token):=20standard=20gets=20its=20ow?= =?UTF-8?q?n=20default=20license=20=E2=80=94=20fee=20amount=20removed=20fr?= =?UTF-8?q?om=20the=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoritative fee is the on-chain fee_numerator (owner-rotatable via set_game_fee); a '(default 5%)' baked into the license text goes stale on the first fee change. The standard's v1.1 text points at the on-chain declaration and resolves rate + payee at time of payment, and states what is unrestricted (non-monetized integration, indexing, display). The legacy registry keeps its v1.0 text — deployed denshokan carries it, frozen. Full package 1212/0/0, zero diagnostics under the pr-ci gate. Co-Authored-By: Claude Fable 5 --- packages/interfaces/src/token/creator.cairo | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/interfaces/src/token/creator.cairo b/packages/interfaces/src/token/creator.cairo index 2a3f3a2a..8f9f3629 100644 --- a/packages/interfaces/src/token/creator.cairo +++ b/packages/interfaces/src/token/creator.cairo @@ -8,9 +8,22 @@ // only), 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, default_license}; +pub use crate::registry::{DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR}; pub use crate::structs::token::GameCreatorInfo; +/// Default license text for the STANDARD token's creator 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 creator address in effect at the time of payment (game_creator_info). 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_creator_info()->(ContractAddress,(Array,felt252,usize),u16) /// 0x1879f9741e7b592cc8da6ca5d9cf83ad687f91b87761744cd80f7a36deed4e From 7295fd66b595fafb640f33f82e0783d52f513c3f Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:31:23 -0700 Subject: [PATCH 30/33] perf(metagame)!: discriminate token generation by address, not an SRC5 probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mint` and `mint_batch_recipients` both opened with a cross-contract `supports_interface` call to decide which token generation they were looking at, then asserted self-boundness on the standard branch. Measured from budokan, which adopted these and backed them out again: for_recipients_empty_panics 12,792,835 -> 12,633,625 -159,210 after_registration_ends 15,046,595 -> 14,809,565 -237,030 player_address_none_defaults 16,397,442 -> 16,079,022 -318,420 per_qualifier_entry_limit 23,028,942 -> 22,740,222 -288,720 ~159k l2_gas per mint, tracking mint count. `enter_tournament` mints once per entry, so a tournament metagame paid it on its most frequent action. The probe was answering a question the next line already asked. Those two lines together say "if standard, require self-bound" — and self-boundness IS what makes a token standard. So the free address comparison becomes the discriminator, and the probe moves to the branch that is already not self-bound, where `assert_not_a_foreign_standard_token` keeps it. That helper is what preserves diagnostics. Without it, a game naming a standard token it does not own would fall through to the legacy branch and die on a missing entrypoint; with it, the panic is the same "Game is not registered" as before. Legacy mints pay for the probe now, standard mints do not. BREAKING, narrowly: a self-bound LEGACY token works today — the probe returns false and the legacy mint dispatches fine — and now takes the standard branch and reverts on the missing 12-arg entrypoint. Reviewed with the metagame owner and accepted knowingly rather than overlooked. The shape is not believed deployed (denshokan is multi-game registry-backed; a single-game legacy token pairs as a separate contract), and the failure is a loud revert at first mint, never silent corruption. The assumption behind that is documented at the discriminator rather than left to the reader, including the part that makes it uncomfortable: "legacy tokens are separate contracts" describes deployed reality, it is not an invariant anything enforces, and `assert_game_registered`'s zero-registry branch would still admit a self-bound legacy token that `mint` now rejects. The two disagree about precisely that shape. Untouched, deliberately: `assert_game_registered`, where the probe is load-bearing — address equality alone would newly admit a self-bound contract that is not a standard token at all — and `get_game_fee_info` / `get_game_creator_address`, which probe the creator surface, a different question equality cannot answer. Raised by the budokan session; reviewed by both game-components sessions before landing. 1212 package tests pass; fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/metagame.cairo | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 051d68f8..9bfd12cd 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -75,6 +75,28 @@ 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 @@ -159,9 +181,21 @@ pub fn mint( 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. - if is_standard_token(minigame_token_address) { - assert_self_bound(minigame_token_address, game_address); + // 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, @@ -180,6 +214,8 @@ pub fn mint( 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 { @@ -246,8 +282,8 @@ pub fn mint_batch_recipients( let minigame_dispatcher = IMinigameDispatcher { contract_address: game_address }; let minigame_token_address = minigame_dispatcher.token_address(); - if is_standard_token(minigame_token_address) { - assert_self_bound(minigame_token_address, game_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 } @@ -267,6 +303,8 @@ pub fn mint_batch_recipients( ); } + 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 } From f5d912fae6d14312dd3d68132cf89f649d86fb65 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:41:16 -0700 Subject: [PATCH 31/33] fix(metagame): guard the fee paths against a zero registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assert_game_registered` admits a single-game legacy token via its `game_registry_address().is_zero()` branch, but `get_game_fee_info` and `get_game_creator_address` walked straight to the registry without that guard. So a game passed the gate and then reverted dispatching at address 0. Mine: I added the zero guard to the gate and did not carry it to the two functions I wrote in the same commit. It is also why budokan kept its own fee resolution instead of delegating. The two questions get different answers, deliberately: * FEE stays total. No registry and no creator surface means the game declares no fee anywhere — and "declares nothing" is the same answer as "declares zero": nobody is owed anything. Returns `fee_numerator: 0` (with the registry's v1.0 default_license, since this branch only ever serves legacy tokens), so `calculate_game_fee` returns 0 and `pay_game_fee` exits before transferring. No caller has to pre-check. * PAYEE stays partial. An absent payee is not a benign zero — paying address 0 burns the funds. Reaching here means a caller is paying a game that never named anyone, which is a caller bug worth surfacing, not a state to encode. Panics with "Metagame: game declares no fee recipient". Collapsing both to a panic would make the common harmless case — a game that simply is not charging — indistinguishable from a broken one, and force every caller to pre-check a question that has a perfectly good answer. Found and diagnosed by the budokan session; the fee/payee asymmetry is their reasoning and it is better than what I first proposed. Full package: 1215 passed / 0 failed, no compilation diagnostics, fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/metagame.cairo | 19 +++++++++ .../src/metagame/tests/test_libs.cairo | 41 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 9bfd12cd..6674c44d 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -5,6 +5,7 @@ use game_components_embeddable_game_standard::minigame::interface::{ }; use game_components_embeddable_game_standard::registry::interface::{ FEE_DENOMINATOR, GameFeeInfo, IMinigameRegistryDispatcher, IMinigameRegistryDispatcherTrait, + default_license, }; use game_components_embeddable_game_standard::token_legacy::interface::{ IMinigameTokenLegacyDispatcher, IMinigameTokenLegacyDispatcherTrait, @@ -412,6 +413,18 @@ pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { 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 creator 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, }; @@ -442,6 +455,12 @@ pub fn get_game_creator_address(game_address: ContractAddress) -> ContractAddres } 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/tests/test_libs.cairo b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo index 3c6b1792..997f4767 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -2230,6 +2230,47 @@ mod legacy_single_game_token { 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_creator_address_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_creator_address(game); + } + #[test] #[should_panic(expected: "Game is not registered")] fn test_assert_game_registered_rejects_mispaired_single_game_token() { From 864ac509ff29badf45bd2cebd3d2f699fe2ca226 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:49:47 -0700 Subject: [PATCH 32/33] =?UTF-8?q?refactor(token)!:=20creator=20=E2=86=92?= =?UTF-8?q?=20game=20fee=20recipient=20=E2=80=94=20the=20payee=20names=20w?= =?UTF-8?q?hat=20it=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-directed rename: "creator" leaves the standard's vocabulary; the payout identity is the GAME FEE RECIPIENT. Unlike the standard-promotion rename, this changes FUNCTION names, so the SRC5 id VALUE changes (extended selectors hash fn names). The retired creator id was never registered by any deployment. Map: - IMinigameTokenCreator → IMinigameTokenGameFee (token/creator.cairo → token/game_fee.cairo) - IMINIGAME_TOKEN_CREATOR_ID (0x21531c…, dead) → IMINIGAME_TOKEN_GAME_FEE_ID = 0x171bf98e08ae98315df3e68477e24275ef5755111c1984db851c344b3907bb0 (src5_rs, selector breakdown in the doc comment) - GameCreatorInfo → GameFeeTerms { recipient, license, fee_numerator } - game_creator_info → game_fee_terms; game_creator_address → game_fee_recipient; set_game_creator_address → set_game_fee_recipient; set_game_fee unchanged - component: CreatorImpl → GameFeeImpl; storage game_creator/ game_creator_license/game_creator_fee_numerator → game_fee_recipient/ game_fee_license/game_fee_numerator (safe: no deployment carries creator storage values — Sepolia GameCore upgraded in place, constructor never re-ran); event GameCreatorUpdate → GameFeeRecipientUpdate; initializer param game_creator → game_fee_recipient; error 'Creator cannot be zero' → 'Fee recipient cannot be zero' - license text: "game creator address" → "game fee recipient" - metagame consumers (atomic cross-half update, authorized by the metagame session, their naming): get_game_creator_address → get_game_fee_recipient, supports_creator_surface → supports_game_fee_surface; get_game_fee_info deliberately UNCHANGED (it returns the registry's GameFeeInfo, a different type); the zero-registry branch keeps the registry's v1.0 default_license (legacy-only path) - mixin ABI + StandardGameMock + tests renamed to match Full package 1215/0/0 (matches the pre-rename baseline exactly — pure rename), presets green, zero diagnostics under the pr-ci gate. Co-Authored-By: Claude Fable 5 --- .../src/metagame/metagame.cairo | 30 ++--- .../src/metagame/metagame_component.cairo | 2 +- .../src/metagame/tests/test_libs.cairo | 24 ++-- .../src/token/AGENTS.md | 26 +++-- .../src/token/minigame_token_component.cairo | 106 +++++++++--------- .../src/token/tests/test_gas_bench.cairo | 4 +- .../src/token/tests/test_token.cairo | 64 +++++------ packages/interfaces/src/AGENTS.md | 4 +- packages/interfaces/src/lib.cairo | 14 +-- packages/interfaces/src/structs.cairo | 2 +- packages/interfaces/src/structs/token.cairo | 11 +- packages/interfaces/src/token.cairo | 8 +- packages/interfaces/src/token/core.cairo | 12 +- packages/interfaces/src/token/creator.cairo | 49 -------- packages/interfaces/src/token/game_fee.cairo | 53 +++++++++ .../src/mocks/standard_game_mock.cairo | 14 +-- 16 files changed, 214 insertions(+), 209 deletions(-) delete mode 100644 packages/interfaces/src/token/creator.cairo create mode 100644 packages/interfaces/src/token/game_fee.cairo diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 6674c44d..157c5c94 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -14,9 +14,9 @@ use game_components_interfaces::structs::token::MintBatchRecipient; use game_components_interfaces::token::core::{ IMINIGAME_TOKEN_ID, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; -use game_components_interfaces::token::creator::{ - IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreatorDispatcher, - IMinigameTokenCreatorDispatcherTrait, +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}; @@ -397,16 +397,16 @@ pub fn calculate_game_fee(revenue: u128, fee_numerator: u16) -> u128 { /// Resolves game fee info. /// /// Standard tokens carry the creator identity the registry used to hold, so a -/// token advertising `IMINIGAME_TOKEN_CREATOR_ID` answers directly. Legacy +/// 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(); - if supports_creator_surface(minigame_token_address) { + if supports_game_fee_surface(minigame_token_address) { // The creator surface belongs to the self-bound standard token. assert_self_bound(minigame_token_address, game_address); - let info = IMinigameTokenCreatorDispatcher { contract_address: minigame_token_address } - .game_creator_info(); + 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 { @@ -433,25 +433,25 @@ pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { } /// True when the token exposes the standard creator surface -/// (`IMINIGAME_TOKEN_CREATOR_ID`) that replaced the registry's fee/payee role. -pub fn supports_creator_surface(token_address: ContractAddress) -> bool { +/// (`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_CREATOR_ID) + .supports_interface(IMINIGAME_TOKEN_GAME_FEE_ID) } /// Resolves the address that should receive a game's creator fee. /// -/// Standard tokens name the payee directly (`game_creator_address`). Legacy +/// 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_creator_address(game_address: ContractAddress) -> ContractAddress { +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_creator_surface(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 IMinigameTokenCreatorDispatcher { contract_address: token_address } - .game_creator_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(); diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index adfa7624..6de6e802 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -133,7 +133,7 @@ pub mod MetagameComponent { // 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_creator_address(game_address); + let recipient = libs::get_game_fee_recipient(game_address); // Transfer fee. ERC20s that signal failure by returning false // instead of reverting must not be reported as a paid fee. 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 997f4767..e8d3479f 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -2011,8 +2011,8 @@ mod standard_token_paths { use game_components_embeddable_game_standard::token::interface::{ IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, }; - use game_components_interfaces::token::creator::{ - IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, + use game_components_interfaces::token::game_fee::{ + IMinigameTokenGameFeeDispatcher, IMinigameTokenGameFeeDispatcherTrait, }; use game_components_testing::constants::{ALICE, BOB, OWNER}; use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; @@ -2184,20 +2184,18 @@ mod standard_token_paths { fn test_get_game_fee_info_reads_creator_surface() { let game = deploy_standard_game(ALICE()); let fee_info = libs::get_game_fee_info(game); - let declared = IMinigameTokenCreatorDispatcher { contract_address: game }; + let declared = IMinigameTokenGameFeeDispatcher { contract_address: game }; assert!( - fee_info.fee_numerator == declared.game_creator_info().fee_numerator, + 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_creator_address_is_the_declared_payee() { + fn test_get_game_fee_recipient_is_the_declared_payee() { let game = deploy_standard_game(ALICE()); - assert!( - libs::get_game_creator_address(game) == ALICE(), "payee is not the declared creator", - ); + assert!(libs::get_game_fee_recipient(game) == ALICE(), "payee is not the declared creator"); } } @@ -2263,12 +2261,12 @@ mod legacy_single_game_token { /// 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_creator_address_rejects_undeclared_payee() { + 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_creator_address(game); + libs::get_game_fee_recipient(game); } #[test] @@ -2372,10 +2370,10 @@ mod hostile_game_paths { /// The payee must not be redirectable to a foreign token's creator. #[test] #[should_panic(expected: "Game is not registered")] - fn test_get_game_creator_address_rejects_foreign_standard_token() { + 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_creator_address(hostile); + libs::get_game_fee_recipient(hostile); } /// The self-bound game itself still works through all three paths. @@ -2383,7 +2381,7 @@ mod hostile_game_paths { fn test_self_bound_game_still_passes_every_path() { let game = deploy_standard_game(ALICE()); libs::assert_game_registered(game); - assert!(libs::get_game_creator_address(game) == ALICE(), "payee should be the creator"); + assert!(libs::get_game_fee_recipient(game) == ALICE(), "payee should be the creator"); assert!(libs::get_game_fee_info(game).fee_numerator == 500, "default fee is 500 bps"); } } diff --git a/packages/embeddable_game_standard/src/token/AGENTS.md b/packages/embeddable_game_standard/src/token/AGENTS.md index 35b6e57a..4d2efee1 100644 --- a/packages/embeddable_game_standard/src/token/AGENTS.md +++ b/packages/embeddable_game_standard/src/token/AGENTS.md @@ -23,7 +23,7 @@ two-phase init, a standalone preset, game-side call helpers). | 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` | -| Creator identity is standard, not optional | The registry's `game_fee_info` role moves onto the token: `game_creator` (payout sink), license and fee (bps, default 500) are set in the initializer and served via `CreatorImpl` (`IMinigameTokenCreator`, `IMINIGAME_TOKEN_CREATOR_ID`). Setters are gated on the game contract's OZ Ownable OWNER (`assert_only_owner`, hard `OwnableComponent::HasComponent` bound) — the stored creator is a payee, not an admin. Monetization platforms resolve the payee LIVE at claim time | +| 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) @@ -66,11 +66,11 @@ require a new contract generation (accepted trade-off). exclusion from `IMINIGAME_TOKEN_LEGACY_ID`) Defined in `packages/interfaces/src/token/core.cairo`. -`initializer(game_creator, license, fee_numerator)` stores the creator -identity (creator must be non-zero; `license`/`fee_numerator` default to +`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 creator surface's `IMINIGAME_TOKEN_CREATOR_ID` — and nothing else: SRC5 +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. @@ -89,11 +89,13 @@ The absorbed minter registry additionally exposes the unchanged `IMinigameTokenMinter` surface (`get_minter_address`, `get_minter_id`, `minter_exists`, `total_minters`) via `MinigameTokenComponent::MinterImpl`. -The creator surface (`MinigameTokenComponent::CreatorImpl`, -`IMINIGAME_TOKEN_CREATOR_ID = 0x21531ca59c09f4a8554a0c390d8054188d27b19148c9039f0279f2b66a86de7`) -exposes `game_creator_info` / `game_creator_address` (reads) and -`set_game_creator_address` / `set_game_fee` (owner-gated writes; rotation to -zero rejected, fee capped at `FEE_DENOMINATOR`). +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): @@ -116,12 +118,12 @@ 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`, `CreatorImpl`) remain exported; a contract wiring them +`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 `CreatorImpl` and the mixin — the owner administers -the creator surface), and an `ERC721HooksTrait` +`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 diff --git a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo index c141d68a..7334287d 100644 --- a/packages/embeddable_game_standard/src/token/minigame_token_component.cairo +++ b/packages/embeddable_game_standard/src/token/minigame_token_component.cairo @@ -52,9 +52,9 @@ pub mod MinigameTokenComponent { use game_components_interfaces::token::core::{ IMINIGAME_TOKEN_ID, IMinigameToken, MinigameTokenABI, }; - use game_components_interfaces::token::creator::{ - DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameCreatorInfo, IMINIGAME_TOKEN_CREATOR_ID, - IMinigameTokenCreator, default_license, + 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, @@ -87,13 +87,13 @@ pub mod MinigameTokenComponent { minter_counter: u64, minter_addresses: Map, minter_id_by_address: Map, - // Creator identity + monetization terms. Replaces the retired + // 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_creator: ContractAddress, - game_creator_license: ByteArray, - game_creator_fee_numerator: u16, + game_fee_recipient: ContractAddress, + game_fee_license: ByteArray, + game_fee_numerator: u16, } #[event] @@ -101,7 +101,7 @@ pub mod MinigameTokenComponent { pub enum Event { MetadataUpdate: MetadataUpdate, MinterRegistryUpdate: MinterRegistryUpdate, - GameCreatorUpdate: GameCreatorUpdate, + GameFeeRecipientUpdate: GameFeeRecipientUpdate, GameFeeUpdate: GameFeeUpdate, } @@ -121,11 +121,11 @@ pub mod MinigameTokenComponent { pub minter_address: ContractAddress, } - /// Emitted when the creator identity is set or rotated. + /// Emitted when the game fee recipient is set or rotated. #[derive(Drop, starknet::Event)] - pub struct GameCreatorUpdate { + pub struct GameFeeRecipientUpdate { #[key] - pub creator: ContractAddress, + pub recipient: ContractAddress, } /// Emitted when the license / fee terms change. @@ -461,40 +461,40 @@ pub mod MinigameTokenComponent { } } - /// Creator identity is standard, not optional: with the registry retired, + /// 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 creator is a payout sink; the + /// 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(CreatorImpl)] - pub impl Creator< + #[embeddable_as(GameFeeImpl)] + pub impl GameFee< TContractState, +HasComponent, impl Own: OwnableComponent::HasComponent, +Drop, - > of IMinigameTokenCreator> { - fn game_creator_info(self: @ComponentState) -> GameCreatorInfo { - GameCreatorInfo { - creator: self.game_creator.read(), - license: self.game_creator_license.read(), - fee_numerator: self.game_creator_fee_numerator.read(), + > 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_creator_address(self: @ComponentState) -> ContractAddress { - self.game_creator.read() + fn game_fee_recipient(self: @ComponentState) -> ContractAddress { + self.game_fee_recipient.read() } - fn set_game_creator_address( - ref self: ComponentState, new_creator: ContractAddress, + 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_creator.is_zero(), "MinigameToken: Creator cannot be zero"); - self.game_creator.write(new_creator); - self.emit(GameCreatorUpdate { creator: new_creator }); + 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( @@ -505,17 +505,17 @@ pub mod MinigameTokenComponent { fee_numerator <= FEE_DENOMINATOR, "MinigameToken: Fee numerator exceeds denominator", ); - self.game_creator_license.write(license.clone()); - self.game_creator_fee_numerator.write(fee_numerator); + 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 + creator), mirroring OZ's ERC20MixinImpl pattern. The + /// 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 / - /// CreatorImpl separately — makes it impossible for the advertised ids to + /// 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. @@ -655,22 +655,22 @@ pub mod MinigameTokenComponent { Minter::total_minters(self) } - // IMinigameTokenCreator - fn game_creator_info(self: @ComponentState) -> GameCreatorInfo { - Creator::game_creator_info(self) + // IMinigameTokenGameFee + fn game_fee_terms(self: @ComponentState) -> GameFeeTerms { + GameFee::game_fee_terms(self) } - fn game_creator_address(self: @ComponentState) -> ContractAddress { - Creator::game_creator_address(self) + fn game_fee_recipient(self: @ComponentState) -> ContractAddress { + GameFee::game_fee_recipient(self) } - fn set_game_creator_address( - ref self: ComponentState, new_creator: ContractAddress, + fn set_game_fee_recipient( + ref self: ComponentState, new_recipient: ContractAddress, ) { - Creator::set_game_creator_address(ref self, new_creator) + GameFee::set_game_fee_recipient(ref self, new_recipient) } fn set_game_fee( ref self: ComponentState, license: ByteArray, fee_numerator: u16, ) { - Creator::set_game_fee(ref self, license, fee_numerator) + GameFee::set_game_fee(ref self, license, fee_numerator) } } @@ -704,10 +704,10 @@ pub mod MinigameTokenComponent { minter_id } - /// Stores the creator identity and registers the SRC5 interface ids: + /// Stores the game fee recipient + terms and registers the SRC5 ids: /// `IMINIGAME_TOKEN_ID`, the absorbed minter's - /// `IMINIGAME_TOKEN_MINTER_ID` and the creator surface's - /// `IMINIGAME_TOKEN_CREATOR_ID`. There is no game argument — the + /// `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`). @@ -715,7 +715,7 @@ pub mod MinigameTokenComponent { /// 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` + `CreatorImpl` all together. + /// `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 @@ -723,28 +723,28 @@ pub mod MinigameTokenComponent { /// deploy — an integration test that exercises fee payment is what /// catches a partial wiring before production does. /// - /// `game_creator` must be non-zero (it is the monetization payee); + /// `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_creator: ContractAddress, + game_fee_recipient: ContractAddress, license: Option, fee_numerator: Option, ) { - assert!(!game_creator.is_zero(), "MinigameToken: Creator cannot be zero"); + 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_creator.write(game_creator); + self.game_fee_recipient.write(game_fee_recipient); let license_value = match license { Option::Some(l) => l, Option::None => default_license(), }; - self.game_creator_license.write(license_value); - self.game_creator_fee_numerator.write(fee); - self.emit(GameCreatorUpdate { creator: game_creator }); + 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); @@ -752,7 +752,7 @@ pub mod MinigameTokenComponent { // 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_CREATOR_ID); + src5_component.register_interface(IMINIGAME_TOKEN_GAME_FEE_ID); } /// Combined ownership + playability guard for the embedding game's 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 index 0ce220a6..3084493c 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_gas_bench.cairo @@ -68,8 +68,8 @@ fn setup_standard() -> (IMinigameTokenDispatcher, ERC721ABIDispatcher, ContractA name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - let game_creator: starknet::ContractAddress = 'GAME_CREATOR'.try_into().unwrap(); - game_creator.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(); diff --git a/packages/embeddable_game_standard/src/token/tests/test_token.cairo b/packages/embeddable_game_standard/src/token/tests/test_token.cairo index 3b15a7dd..98fa2915 100644 --- a/packages/embeddable_game_standard/src/token/tests/test_token.cairo +++ b/packages/embeddable_game_standard/src/token/tests/test_token.cairo @@ -1,7 +1,7 @@ use game_components_interfaces::structs::metagame::{GameContext, GameContextDetails}; -use game_components_interfaces::token::creator::{ - DEFAULT_GAME_FEE_BPS, FEE_DENOMINATOR, GameCreatorInfo, IMINIGAME_TOKEN_CREATOR_ID, - IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, default_license, +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, @@ -45,8 +45,8 @@ fn MINTER() -> ContractAddress { addr('MINTER') } -fn GAME_CREATOR() -> ContractAddress { - addr('GAME_CREATOR') +fn FEE_RECIPIENT() -> ContractAddress { + addr('FEE_RECIPIENT') } fn OWNER() -> ContractAddress { @@ -66,7 +66,7 @@ fn deploy_token() -> ( name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - GAME_CREATOR().serialize(ref calldata); + FEE_RECIPIENT().serialize(ref calldata); OWNER().serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); ( @@ -905,52 +905,52 @@ fn test_mint_batch_shares_restored_fields_and_url() { // CREATOR SURFACE (owner-administered payout identity) // ================================================================================================ -fn creator_of(token: IMinigameTokenDispatcher) -> IMinigameTokenCreatorDispatcher { - IMinigameTokenCreatorDispatcher { contract_address: token.contract_address } +fn game_fee_of(token: IMinigameTokenDispatcher) -> IMinigameTokenGameFeeDispatcher { + IMinigameTokenGameFeeDispatcher { contract_address: token.contract_address } } #[test] -fn test_creator_registered_with_defaults() { +fn test_game_fee_registered_with_defaults() { let (token, _, _) = deploy_token(); - let creator = creator_of(token); + let game_fee = game_fee_of(token); let src5 = ISRC5Dispatcher { contract_address: token.contract_address }; assert!( - src5.supports_interface(IMINIGAME_TOKEN_CREATOR_ID), - "Should register the creator interface id", + src5.supports_interface(IMINIGAME_TOKEN_GAME_FEE_ID), + "Should register the game-fee interface id", ); - assert!(creator.game_creator_address() == GAME_CREATOR(), "Creator address mismatch"); - let info = creator.game_creator_info(); - let expected = GameCreatorInfo { - creator: GAME_CREATOR(), license: default_license(), fee_numerator: DEFAULT_GAME_FEE_BPS, + 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_creator_and_sets_fee() { +fn test_owner_rotates_recipient_and_sets_fee() { let (token, _, _) = deploy_token(); - let creator = creator_of(token); + let game_fee = game_fee_of(token); cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(2)); - creator.set_game_creator_address(BOB()); - creator.set_game_fee("Custom license", 1000); + game_fee.set_game_fee_recipient(BOB()); + game_fee.set_game_fee("Custom license", 1000); - let info = creator.game_creator_info(); - assert!(info.creator == BOB(), "Rotation should take effect"); + 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_creator_itself_cannot_rotate() { - // The stored creator is a payout sink, not an admin: only the contract +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, GAME_CREATOR(), CheatSpan::TargetCalls(1)); - creator_of(token).set_game_creator_address(BOB()); + cheat_caller_address(token.contract_address, FEE_RECIPIENT(), CheatSpan::TargetCalls(1)); + game_fee_of(token).set_game_fee_recipient(BOB()); } #[test] @@ -958,15 +958,15 @@ fn test_creator_itself_cannot_rotate() { fn test_non_owner_cannot_set_fee() { let (token, _, _) = deploy_token(); cheat_caller_address(token.contract_address, ALICE(), CheatSpan::TargetCalls(1)); - creator_of(token).set_game_fee("hijack", 0); + game_fee_of(token).set_game_fee("hijack", 0); } #[test] -#[should_panic(expected: "MinigameToken: Creator cannot be zero")] +#[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)); - creator_of(token).set_game_creator_address(addr(0)); + game_fee_of(token).set_game_fee_recipient(addr(0)); } #[test] @@ -974,11 +974,11 @@ fn test_rotation_to_zero_rejected() { fn test_fee_above_denominator_rejected() { let (token, _, _) = deploy_token(); cheat_caller_address(token.contract_address, OWNER(), CheatSpan::TargetCalls(1)); - creator_of(token).set_game_fee("too greedy", FEE_DENOMINATOR + 1); + game_fee_of(token).set_game_fee("too greedy", FEE_DENOMINATOR + 1); } #[test] -fn test_zero_creator_deploy_rejected() { +fn test_zero_recipient_deploy_rejected() { let contract = declare("StandardGameMock").unwrap().contract_class(); let mut calldata: Array = array![]; let name: ByteArray = "StandardToken"; @@ -989,5 +989,5 @@ fn test_zero_creator_deploy_rejected() { base_uri.serialize(ref calldata); addr(0).serialize(ref calldata); OWNER().serialize(ref calldata); - assert!(contract.deploy(@calldata).is_err(), "Zero creator must fail the constructor"); + assert!(contract.deploy(@calldata).is_err(), "Zero recipient must fail the constructor"); } diff --git a/packages/interfaces/src/AGENTS.md b/packages/interfaces/src/AGENTS.md index ea85a994..be0a0236 100644 --- a/packages/interfaces/src/AGENTS.md +++ b/packages/interfaces/src/AGENTS.md @@ -9,7 +9,7 @@ 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` (`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/creator` | `IMinigameTokenCreator` | Creator payout identity + fee terms on the standard token (replaces the registry's `game_fee_info`); setters gated on the game contract's Ownable owner | +| `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 | @@ -36,7 +36,7 @@ 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_CREATOR_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...; ``` diff --git a/packages/interfaces/src/lib.cairo b/packages/interfaces/src/lib.cairo index 9bee7f06..1d3a76b1 100644 --- a/packages/interfaces/src/lib.cairo +++ b/packages/interfaces/src/lib.cairo @@ -98,7 +98,7 @@ pub use registry::{ // Structs pub use structs::{ - GameContext, GameContextDetails, GameCreatorInfo, GameDetail, GameFeeInfo, GameMetadata, + GameContext, GameContextDetails, GameDetail, GameFeeInfo, GameFeeTerms, GameMetadata, GameObjective, GameObjectiveDetails, GameSetting, GameSettingDetails, LeaderboardConfig, LeaderboardEntry, LeaderboardResult, LeaderboardStoreConfig, Lifecycle, MintBatchRecipient, MintGameParams, MintParams, PlayerNameUpdate, TokenMetadata, @@ -106,13 +106,13 @@ pub use structs::{ // Token pub use token::{ - IMINIGAME_TOKEN_CONTEXT_ID, IMINIGAME_TOKEN_CREATOR_ID, IMINIGAME_TOKEN_ID, + 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, IMinigameTokenCreator, - IMinigameTokenCreatorDispatcher, IMinigameTokenCreatorDispatcherTrait, IMinigameTokenDispatcher, - IMinigameTokenDispatcherTrait, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, - IMinigameTokenLegacyDispatcherTrait, IMinigameTokenMinter, IMinigameTokenMinterDispatcher, - IMinigameTokenMinterDispatcherTrait, IMinigameTokenObjectives, + 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, IMinigameTokenSettings, IMinigameTokenSettingsDispatcher, IMinigameTokenSettingsDispatcherTrait, diff --git a/packages/interfaces/src/structs.cairo b/packages/interfaces/src/structs.cairo index 4fcd7e5a..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::{ - GameCreatorInfo, Lifecycle, MintBatchRecipient, MintParams, PlayerNameUpdate, TokenFullState, + 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 d8bb9097..2610cc85 100644 --- a/packages/interfaces/src/structs/token.cairo +++ b/packages/interfaces/src/structs/token.cairo @@ -3,12 +3,13 @@ use starknet::ContractAddress; use super::metagame::GameContextDetails; -/// Creator identity + monetization terms of a self-bound standard token. -/// Replaces the retired registry's `GameFeeInfo` lookup: the payee and fee -/// live on the game/token contract itself (see `token::creator`). +/// 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 GameCreatorInfo { - pub creator: ContractAddress, +pub struct GameFeeTerms { + pub recipient: ContractAddress, pub license: ByteArray, /// Fee in basis points (against `FEE_DENOMINATOR` = 10_000) pub fee_numerator: u16, diff --git a/packages/interfaces/src/token.cairo b/packages/interfaces/src/token.cairo index 0c9c9669..d7f2c998 100644 --- a/packages/interfaces/src/token.cairo +++ b/packages/interfaces/src/token.cairo @@ -2,7 +2,7 @@ pub mod context; pub mod core; -pub mod creator; +pub mod game_fee; pub mod legacy; pub mod minter; pub mod objectives; @@ -16,9 +16,9 @@ pub use core::{ IMINIGAME_TOKEN_ID, IMinigameToken, IMinigameTokenDispatcher, IMinigameTokenDispatcherTrait, MinigameTokenABI, MinigameTokenABIDispatcher, MinigameTokenABIDispatcherTrait, }; -pub use creator::{ - IMINIGAME_TOKEN_CREATOR_ID, IMinigameTokenCreator, IMinigameTokenCreatorDispatcher, - IMinigameTokenCreatorDispatcherTrait, +pub use game_fee::{ + IMINIGAME_TOKEN_GAME_FEE_ID, IMinigameTokenGameFee, IMinigameTokenGameFeeDispatcher, + IMinigameTokenGameFeeDispatcherTrait, }; pub use legacy::{ IMINIGAME_TOKEN_LEGACY_ID, IMinigameTokenLegacy, IMinigameTokenLegacyDispatcher, diff --git a/packages/interfaces/src/token/core.cairo b/packages/interfaces/src/token/core.cairo index 5164324f..6867d70f 100644 --- a/packages/interfaces/src/token/core.cairo +++ b/packages/interfaces/src/token/core.cairo @@ -148,7 +148,7 @@ pub trait IMinigameToken { /// Combined mixin ABI: the full external surface of the standard token — /// `IMinigameToken` + the absorbed minter (`IMinigameTokenMinter`) + the -/// creator surface (`IMinigameTokenCreator`) — as ONE embeddable trait, +/// game-fee surface (`IMinigameTokenGameFee`) — as ONE embeddable trait, /// mirroring OpenZeppelin's ERC20ABI / MixinImpl pattern. /// /// The component's `initializer` registers all three SRC5 ids @@ -156,7 +156,7 @@ pub trait IMinigameToken { /// (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_CREATOR_ID`. +/// `IMINIGAME_TOKEN_MINTER_ID` and `IMINIGAME_TOKEN_GAME_FEE_ID`. #[starknet::interface] pub trait MinigameTokenABI { // IMinigameToken @@ -209,9 +209,9 @@ pub trait MinigameTokenABI { fn minter_exists(self: @TState, minter_address: ContractAddress) -> bool; fn total_minters(self: @TState) -> u64; - // IMinigameTokenCreator (creator payout identity) - fn game_creator_info(self: @TState) -> crate::structs::token::GameCreatorInfo; - fn game_creator_address(self: @TState) -> ContractAddress; - fn set_game_creator_address(ref self: TState, new_creator: ContractAddress); + // 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/creator.cairo b/packages/interfaces/src/token/creator.cairo deleted file mode 100644 index 8f9f3629..00000000 --- a/packages/interfaces/src/token/creator.cairo +++ /dev/null @@ -1,49 +0,0 @@ -// Token creator extension interface -// -// The registry used to carry a game's creator identity and monetization fee -// (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 identity -// lives on the token standard itself: set at initialization, administered by -// the game contract's OZ Ownable OWNER (the stored creator is a payout sink -// only), 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::GameCreatorInfo; - -/// Default license text for the STANDARD token's creator 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 creator address in effect at the time of payment (game_creator_info). 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_creator_info()->(ContractAddress,(Array,felt252,usize),u16) -/// 0x1879f9741e7b592cc8da6ca5d9cf83ad687f91b87761744cd80f7a36deed4e -/// - game_creator_address()->ContractAddress -/// 0x303788bd08cbf196171a0b6fcb0c815715fb3916ee1c10b3d67d6a842650b1e -/// - set_game_creator_address(ContractAddress) -/// 0x23fb1fc00d3dc31c6c8b33b1a262d95b2fff025a12cef8aaf076874fdcf7255 -/// - set_game_fee((Array,felt252,usize),u16) -/// 0x3318144fd81873b0e256922d3972f42e61a473c6336dfcc2e9f2e8defdcf9e2 -pub const IMINIGAME_TOKEN_CREATOR_ID: felt252 = - 0x21531ca59c09f4a8554a0c390d8054188d27b19148c9039f0279f2b66a86de7; - -#[starknet::interface] -pub trait IMinigameTokenCreator { - fn game_creator_info(self: @TState) -> GameCreatorInfo; - fn game_creator_address(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_creator_address(ref self: TState, new_creator: 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/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/test_common/src/mocks/standard_game_mock.cairo b/packages/test_common/src/mocks/standard_game_mock.cairo index 0a49a1b2..00e84f0a 100644 --- a/packages/test_common/src/mocks/standard_game_mock.cairo +++ b/packages/test_common/src/mocks/standard_game_mock.cairo @@ -63,8 +63,8 @@ pub mod StandardGameMock { 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 CreatorImpl (hard HasComponent bound): - // the creator surface is administered by the contract owner. + // 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] @@ -113,7 +113,7 @@ pub mod StandardGameMock { #[abi(embed_v0)] impl SRC5Impl = SRC5Component::SRC5Impl; // One embed for the full standard surface (token + absorbed minter + - // creator) — the mixin keeps the SRC5 ids registered by the initializer + // game fee) — the mixin keeps the SRC5 ids registered by the initializer // honest by construction. #[abi(embed_v0)] impl MinigameTokenMixinImpl = @@ -159,17 +159,17 @@ pub mod StandardGameMock { name: ByteArray, symbol: ByteArray, base_uri: ByteArray, - game_creator: ContractAddress, + game_fee_recipient: ContractAddress, owner: ContractAddress, ) { self.erc721.initializer(name, symbol, base_uri); - // The owner administers the creator surface (assert_only_owner gate). + // 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 creator surface's IMINIGAME_TOKEN_CREATOR_ID (creator set + // 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_creator, Option::None, Option::None); + 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); From 4b2023d6230591a49cf964c4afd476cd3c60ca38 Mon Sep 17 00:00:00 2001 From: Starknet Dev <42612612+starknetdev@users.noreply.github.com> Date: Tue, 25 Aug 2026 05:02:51 -0700 Subject: [PATCH 33/33] docs(metagame): finish the creator -> game fee recipient rename in prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 864ac50 renamed the symbols; a symbol sweep cannot touch comments, so the metagame half was left saying "creator" next to code that now says `game_fee_recipient` — the exact gap where a rename leaves a codebase reading wrong to the next person while testing green. Doc comments on assert_self_bound, get_game_fee_info, supports_game_fee_surface and get_game_fee_recipient; the test that embedded the old name; assert messages; the module AGENTS.md helper table. `pay_game_fee`'s doc was stale in a second way and predates the rename entirely: "Reads fee from registry, calculates amount, transfers via ERC20 to the game's creator token owner". It has not been registry-only since 2939fc9 added the dual-generation branch, and since f5d912f it also short-circuits on a zero fee before it ever needs a recipient. Rewritten to describe what it does. Deliberately NOT renamed: `register_game(creator_address)` in the registry mock — that is the registry's own parameter and the registry surface keeps its vocabulary. Full package: 1215 passed / 0 failed, no compilation diagnostics, fmt clean — count unchanged from 864ac50, as a comment-and-name-only change should be. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/metagame/AGENTS.md | 2 +- .../src/metagame/metagame.cairo | 16 ++++++------- .../src/metagame/metagame_component.cairo | 7 ++++-- .../src/metagame/tests/test_libs.cairo | 23 +++++++++++-------- .../tests/test_metagame_component.cairo | 4 ++-- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/embeddable_game_standard/src/metagame/AGENTS.md b/packages/embeddable_game_standard/src/metagame/AGENTS.md index 3c16dd68..ca3beede 100644 --- a/packages/embeddable_game_standard/src/metagame/AGENTS.md +++ b/packages/embeddable_game_standard/src/metagame/AGENTS.md @@ -40,7 +40,7 @@ legacy callback receiver. The component now exposes internals only. | `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)` / `pay_game_fee(...)` | Resolve fee terms / pay the game creator | +| `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 diff --git a/packages/embeddable_game_standard/src/metagame/metagame.cairo b/packages/embeddable_game_standard/src/metagame/metagame.cairo index 157c5c94..76eac17c 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame.cairo @@ -65,7 +65,7 @@ pub fn assert_game_registered(game_address: ContractAddress) { /// 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 creator. +/// 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"); } @@ -396,14 +396,14 @@ pub fn calculate_game_fee(revenue: u128, fee_numerator: u16) -> u128 { /// Resolves game fee info. /// -/// Standard tokens carry the creator identity 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. +/// 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(); if supports_game_fee_surface(minigame_token_address) { - // The creator surface belongs to the self-bound standard token. + // 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(); @@ -414,7 +414,7 @@ pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { }; 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 creator surface, + // 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: @@ -432,14 +432,14 @@ pub fn get_game_fee_info(game_address: ContractAddress) -> GameFeeInfo { minigame_registry_dispatcher.game_fee_info(game_id) } -/// True when the token exposes the standard creator surface +/// 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 creator fee. +/// 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 diff --git a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo index 6de6e802..126a2e73 100644 --- a/packages/embeddable_game_standard/src/metagame/metagame_component.cairo +++ b/packages/embeddable_game_standard/src/metagame/metagame_component.cairo @@ -116,8 +116,11 @@ pub mod MetagameComponent { ) } - /// Reads fee from registry, calculates amount, transfers via ERC20 - /// to the game's creator token owner. Returns fee amount (0 if no fee). + /// 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, 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 e8d3479f..edbb347c 100644 --- a/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo +++ b/packages/embeddable_game_standard/src/metagame/tests/test_libs.cairo @@ -2021,7 +2021,7 @@ mod standard_token_paths { use crate::metagame::metagame as libs; /// One contract that is both the game and the standard token. - fn deploy_standard_game(game_creator: ContractAddress) -> ContractAddress { + 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"; @@ -2030,7 +2030,7 @@ mod standard_token_paths { name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - game_creator.serialize(ref calldata); + game_fee_recipient.serialize(ref calldata); OWNER().serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); contract_address @@ -2178,10 +2178,10 @@ mod standard_token_paths { ); } - /// The creator surface replaces the registry's fee role — previously this + /// 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_creator_surface() { + 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 }; @@ -2195,7 +2195,10 @@ mod standard_token_paths { #[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 creator"); + assert!( + libs::get_game_fee_recipient(game) == ALICE(), + "payee is not the declared fee recipient", + ); } } @@ -2302,7 +2305,7 @@ mod legacy_single_game_token { // 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 creator — at a fee rate the attacker controls. +// or pay its fee recipient — at a fee rate the attacker controls. #[cfg(test)] mod hostile_game_paths { @@ -2311,7 +2314,7 @@ mod hostile_game_paths { use starknet::ContractAddress; use crate::metagame::metagame as libs; - fn deploy_standard_game(game_creator: ContractAddress) -> ContractAddress { + 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"; @@ -2320,7 +2323,7 @@ mod hostile_game_paths { name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - game_creator.serialize(ref calldata); + game_fee_recipient.serialize(ref calldata); OWNER().serialize(ref calldata); let (contract_address, _) = contract.deploy(@calldata).unwrap(); contract_address @@ -2367,7 +2370,7 @@ mod hostile_game_paths { libs::get_game_fee_info(hostile); } - /// The payee must not be redirectable to a foreign token's creator. + /// 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() { @@ -2381,7 +2384,7 @@ mod hostile_game_paths { 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 creator"); + 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 8bac0628..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 @@ -1308,7 +1308,7 @@ pub mod MockFeePayer { } } -/// Deploys a standard game (creator surface, default 500 bps fee) plus the +/// 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(); @@ -1319,7 +1319,7 @@ fn deploy_fee_fixture() -> (ContractAddress, ContractAddress) { name.serialize(ref calldata); symbol.serialize(ref calldata); base_uri.serialize(ref calldata); - ALICE().serialize(ref calldata); // game creator = fee payee + ALICE().serialize(ref calldata); // game fee recipient = payee OWNER().serialize(ref calldata); let (game_address, _) = game.deploy(@calldata).unwrap();