diff --git a/README.md b/README.md index e1d02dcd..4d1cc792 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,11 @@ Core components for onboarding games onto Starknet. Components for applications that coordinate and interact with games. +Leaderboard, registration, entry-requirement, entry-fee and prize APIs also support +application-owned storage without embedding their components. See +[host-owned storage](packages/metagame/HOST_STORAGE.md) for configuration providers, +optional component adapters and upgrade compatibility. + | Module | Description | Docs | |--------|-------------|------| | [**leaderboard**](packages/metagame/src/leaderboard/) | Tournament scoring, ranking, and multi-tournament support | [README](packages/metagame/src/leaderboard/README.md) | diff --git a/packages/metagame/HOST_STORAGE.md b/packages/metagame/HOST_STORAGE.md new file mode 100644 index 00000000..bce00a9e --- /dev/null +++ b/packages/metagame/HOST_STORAGE.md @@ -0,0 +1,255 @@ +# Host-owned metagame storage + +An application can use the shared metagame APIs and algorithms with its own +storage. It does not need to embed a component or maintain a separate copy +of their interface implementations. The standard components remain available +with their existing storage layout, embedded aliases and admin interfaces. + +The separation is: + +- `Store` describes reads and writes, independently of a Starknet component. +- `leaderboard::api::Configuration` supplies leaderboard configuration. +- `api` modules implement public views and internal mutations using those traits. +- `storage_adapter` modules optionally forward storage to existing components. + +This can reduce creation writes when the host already stores the configuration +or can derive it. Using an adapter alone does not reduce storage gas. Packing, +eliding redundant writes and preserving invariants remain host responsibilities. + +## Use only application storage + +Implement `leaderboard::store::Store` and +`leaderboard::api::Configuration`, then embed the shared views: + +```cairo +#[abi(embed_v0)] +impl LeaderboardViews = + game_components_metagame::leaderboard::api::LeaderboardImpl; +``` + +For score submission also implement `leaderboard::hooks::LeaderboardHooksTrait` +and import `LeaderboardInternalImpl` and `LeaderboardInternalTrait` from `api`. +After the host checks authorization, game ownership, eligibility and phase: + +```cairo +let result = LeaderboardInternalTrait::submit_score( + ref self, context_id, token_id, score, position, +); +``` + +The existing sorting, neighbor checks, duplicate detection and mint-block tie +ordering are reused. Positions passed to submission and public views are +1-indexed; positions used by `Store` are 0-indexed. `on_score_submitted` fires +only on `Success`. Other hooks belong to the host's configuration/admin paths. +Game address zero selects stored scores; a nonzero address selects live game +scores for public entry views, as in the standard component. +Configuration providers may override `leaderboard_game_address` and +`leaderboard_max_entries` to avoid reading unused configuration fields in entry +views and capacity checks. Their defaults derive values from `leaderboard_config`; +the standard component overrides both to retain its original single-slot reads. + +For entry requirements implement `entry_requirement::store::Store`: + +```cairo +#[abi(embed_v0)] +impl RequirementViews = + game_components_metagame::entry_requirement::api::EntryRequirementImpl; +``` + +Import `EntryRequirementInternalImpl` and `EntryRequirementInternalTrait` from +`entry_requirement::api` for the validated internal setter: + +```cairo +EntryRequirementInternalTrait::set_entry_requirement( + ref self, context_id, requirement, +); +``` + +The setter checks SRC5 support and rejects a zero extension address before +writing. It does not grant callers permission to configure a context or call +an extension's `add_config`; those remain the host's responsibility. Qualification +validation and counters use the existing `EntryRequirementStoreTrait`. Token +gates verify ownership; extension gates delegate eligibility and any limit to +the extension. When a claimed qualifier is supplied, the extension must verify +that claim. Do not expose these internal methods without the application's checks. + +Extension configuration spans are not stored here: extension contracts own that +data. Requirement views return the stored extension address and an empty span, +matching the standard component. Clearing a requirement does not erase its +qualification history. + +Complete compiling examples using custom maps, with no components, are +[`HostLeaderboard`](src/leaderboard/tests/test_host_storage.cairo) and +[`HostRequirement`](src/entry_requirement/tests/test_host_storage.cairo). Their +configuration and mutation entrypoints are test scaffolding, not production +access-control examples. + +## Retain component maps, customize configuration + +For existing deployments keep the original `component!`, substorage fields and +event declarations. A leaderboard adapter retains all entry, score and position +maps while the host implements the configuration provider: + +```cairo +impl LeaderboardStorage = + game_components_metagame::leaderboard::storage_adapter::ComponentStore; +#[abi(embed_v0)] +impl LeaderboardViews = + game_components_metagame::leaderboard::api::LeaderboardImpl; +``` + +For entry requirements implement `storage_adapter::Metadata` +with the host's metadata reads/writes and select that implementation explicitly: + +```cairo +impl RequirementStorage = + game_components_metagame::entry_requirement::storage_adapter::ComponentStore< + ContractState, _, MyMetadataImpl, + >; +#[abi(embed_v0)] +impl RequirementViews = + game_components_metagame::entry_requirement::api::EntryRequirementImpl; +``` + +This retains token addresses, extension addresses and qualification counts in +the component maps. To retain its metadata map too, use +`storage_adapter::ComponentMetadata` as `MyMetadataImpl`. +Compiling adapter examples appear in the same two test files. + +Embed either the original component views or the shared host views for each +interface, rather than both. Keep the existing component's initialization and +SRC5 registration when retaining its storage. A host without components must +provide any required SRC5 registration itself. Shared internal submission fires +the host hook, so it must not emit the same event a second time manually. + +An upgrade may choose a version marker: old contexts read original component +configuration; new contexts read packed host configuration. Route both views +and mutations through the same adapters. Preserve old map names and substorage +paths, and ensure a rollback class understands any new encoding. The library +does not move existing storage or automatically migrate records. + +## Verification + +The standard component tests exercise the default path. `test_host_storage` +tests exercise custom packed configuration, independent contexts, both ranking +orders, hooks, qualification limits, ownership rejection and component map +adapters. Run them with: + +```sh +snforge test -p game_components_metagame +snforge test -p game_components_metagame test_host_storage +``` + +## Registration, entry fees and prizes + +The same separation is available for all five metagame modules: + +| Module | Shared views | Shared internal operations | Optional original-map adapter | +| --- | --- | --- | --- | +| `leaderboard` | `api::LeaderboardImpl` | `api::LeaderboardInternalImpl` | `storage_adapter::ComponentStore` | +| `entry_requirement` | `api::EntryRequirementImpl` | `api::EntryRequirementInternalImpl` | `storage_adapter::ComponentStore` | +| `registration` | `api::RegistrationImpl` | `api::RegistrationInternalImpl` | `storage_adapter::ComponentStore` | +| `entry_fee` | `api::EntryFeeImpl` | `api::EntryFeeInternalImpl` | `storage_adapter::ComponentStore` | +| `prize` | `api::PrizeImpl` | `api::PrizeInternalImpl` | `storage_adapter::ComponentStore` | + +For each module, implement `store::Store` and embed its shared +views. Import the module's generated internal trait and implementation, or call +its internal implementation explicitly when multiple modules have overlapping +helper names. Existing standard components delegate to these same APIs; their +storage declarations, public signatures, embedded aliases, initializers and +SRC5 registration remain unchanged. + +For example, an application that keeps the original registration maps can use: + +```cairo +component!(path: RegistrationComponent, storage: registration, event: RegistrationEvent); +impl RegistrationStorage = + game_components_metagame::registration::storage_adapter::ComponentStore; +#[abi(embed_v0)] +impl RegistrationViews = + game_components_metagame::registration::api::RegistrationImpl; +``` + +Alternatively, implement `registration::store::Store` against +application maps and omit `RegistrationComponent` entirely. This applies equally +to entry fees and prizes. The independent contracts in +`src/registration/tests/host_fixtures.cairo` and +`src/prize/tests/host_fixtures.cairo` are compiling examples of this approach; +they also include matching component-backed adapters. + +### Combining storage across modules + +`Store` describes logical fields, not a required physical layout. A host can +implement multiple store traits against one packed map. It may also forward +individual fields through `storage_adapter::ComponentStore` using explicit +implementation calls, while storing the remaining fields itself. When defining +a custom `Store` implementation, do not also bind the full component adapter as +a second `Store` implementation in the same scope. + +The registration/fee example stores these fields together, keyed by the same +`(context_id, token_id)` pair: + +- bits 0–63: registration context; +- bit 64: score submitted; +- bit 65: banned; +- bit 66: fee refund claimed. + +Registration reads mask out the refund bit before passing state to the shared +registration decoders. Registration writes preserve the refund bit; fee writes +preserve all registration fields. In particular, replacing a registration entry +clears the displaced token's registration state but **retains its fee claim +history**. Retaining this history prevents a storage update from enabling a +second refund. Contexts remain isolated even when they contain the same token ID. + +The prize example combines extension context (64 bits), custom-share count +(32 bits) and payout position (32 bits) into one application metadata slot. +Each setter preserves the other fields, including at maximum integer values. +This demonstrates flexibility; it does not imply that every prize needs all +three fields or that packing them always saves a write. + +Do not concatenate packed formats without checking their full supported ranges. +Entry-fee data uses 165 bits; the current distribution format can use 88 bits +with Tiered parameters. Their combined 253 bits cannot fit in one felt. Addresses +also require their own capacity. Apps may derive or specialize fields only when +their supported configurations and invariants make that valid. + +### Internal operations and host responsibilities + +The new internal APIs preserve existing validation, revert messages, token +transfers, extension dispatch and claim bookkeeping. They are not externally +embedded. The application must enforce authorization, tournament phase, +registration eligibility, correct deposit/configuration selection, entitlement, +claim-before-transfer ordering and reentrancy protection. A payout/refund helper +alone does not verify entitlement or mark the corresponding claim. + +For deployed applications, keep old maps readable. A new host packing layout +needs an explicit version discriminator or a migration; adding a generic API +does not migrate any data. All original component maps retain their names and +types in this change. Public token-owner payout behavior is unchanged. + +### Measured packing example + +The matched `entry_fee_host_storage_gas_combined` and +`entry_fee_host_storage_gas_separate` tests deploy the fixture, register one game, +mark its score submitted and mark its fee refund claimed. They use the same +shared APIs with either combined application state or separate component maps. +This isolates a packing opportunity rather than measuring a complete tournament, +account validation or a charged network fee. See the benchmark results below. + +| Fixture | L2 gas | L1 data gas | Distinct changed data slots | +| --- | ---: | ---: | ---: | +| Separate component maps | 2,373,361 | 384 | 3 | +| Combined application map | 2,166,402 | 288 | 2 | + +This sequence saves **8.72% L2 gas** and **25% L1 data gas**. +Both paths make four storage-write syscalls; packing reduces the number of +distinct slots changed, rather than the number of setter calls. The combined +store performs extra reads and bit manipulation, already included in these +measurements. Other workloads can have different tradeoffs. + +Measured with Scarb 2.20.1 and snforge 0.63.0 in the dev profile, without account +validation. Reproduce with: + +```bash +snforge test -p game_components_metagame entry_fee_host_storage_gas --max-threads 1 +``` diff --git a/packages/metagame/src/entry_fee.cairo b/packages/metagame/src/entry_fee.cairo index e766899c..8d8cea1a 100644 --- a/packages/metagame/src/entry_fee.cairo +++ b/packages/metagame/src/entry_fee.cairo @@ -1,6 +1,8 @@ +pub mod api; pub mod entry_fee; pub mod entry_fee_component; pub mod entry_fee_store; +pub mod storage_adapter; pub mod store; pub mod structs; diff --git a/packages/metagame/src/entry_fee/api.cairo b/packages/metagame/src/entry_fee/api.cairo new file mode 100644 index 00000000..efe6ba83 --- /dev/null +++ b/packages/metagame/src/entry_fee/api.cairo @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Storage-independent APIs. The host supplies Store and enforces access control, +//! lifecycle eligibility and reentrancy protection before internal mutations. +use core::num::traits::Zero; +use game_components_interfaces::entry_fee::IEntryFee; +use game_components_utilities::distribution::structs::PackedDistributionStorePacking; +use metagame_extensions_interfaces::entry_fee_extension::{ + IENTRY_FEE_EXTENSION_ID, IEntryFeeExtensionDispatcher, IEntryFeeExtensionDispatcherTrait, +}; +use metagame_extensions_interfaces::extension::ExtensionConfig; +use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use starknet::{ContractAddress, get_caller_address, get_contract_address}; +use crate::entry_fee::entry_fee_store::{EntryFeeStoreImpl, EntryFeeStoreTrait}; +use crate::entry_fee::store::Store; +use crate::entry_fee::structs::{ + AdditionalShare, EntryFee, EntryFeeClaimType, EntryFeeConfig, EntryFeeDeposit, +}; + + +#[starknet::embeddable] +pub impl EntryFeeImpl< + TContractState, +Store, +Drop, +> of IEntryFee { + fn get_entry_fee(self: @TContractState, context_id: u64) -> Option { + EntryFeeStoreTrait::get_entry_fee(self, context_id) + } +} + +#[generate_trait] +pub impl EntryFeeInternalImpl< + TContractState, +Store, +Drop, +> of EntryFeeInternalTrait { + /// Get entry fee for a context (internal) + /// Returns None if no entry fee is set (token address is zero) + fn _get_entry_fee(self: @TContractState, context_id: u64) -> Option { + EntryFeeStoreTrait::get_entry_fee(self, context_id) + } + + /// Get additional shares for a context + /// Uses packed storage: reads 1 slot per 16 shares instead of 1 slot per share + fn _get_additional_shares(self: @TContractState, context_id: u64) -> Span { + EntryFeeStoreTrait::get_additional_shares(self, context_id) + } + + /// Persist a custom distribution shares array for a context. + /// Packs 15 `u16` shares per `felt252` slot via `CustomShares`. + /// Consumers pair this with their own paid-places count semantics. + fn _store_distribution_shares(ref self: TContractState, context_id: u64, shares: Span) { + EntryFeeStoreTrait::store_distribution_shares(ref self, context_id, shares); + } + + /// Read `count` custom distribution shares for a context. Callers + /// supply the count (typically sourced from the sibling + /// `PackedDistribution.positions`); returns an empty array when + /// `count == 0`. + fn _get_distribution_shares(self: @TContractState, context_id: u64, count: u32) -> Array { + EntryFeeStoreTrait::get_distribution_shares(self, context_id, count) + } + + /// Read a single custom distribution share at a 1-indexed position. + /// O(1) — only one packed `felt252` slot is read. Use this in claim + /// paths instead of `_get_distribution_shares` when only one share + /// is needed. + fn _get_custom_share_at(self: @TContractState, context_id: u64, position: u32) -> u16 { + EntryFeeStoreTrait::get_custom_share_at(self, context_id, position) + } + + /// Set entry fee or extension for a context. + /// Asserts that no entry fee has been previously set for this context. + /// - EntryFee::Config: stores entry fee config, returns Some(EntryFeeConfig) + /// - EntryFee::Extension: sets extension config, returns None + fn set_entry_fee( + ref self: TContractState, context_id: u64, entry_fee: EntryFee, + ) -> Option { + // Assert entry fee has not already been set (either config or extension) + assert!( + !EntryFeeStoreTrait::is_entry_fee_set(@self, context_id), + "EntryFee: Entry fee already set for context {}", + context_id, + ); + + match entry_fee { + EntryFee::Config(config) => { + EntryFeeStoreTrait::set_entry_fee_config(ref self, context_id, @config); + Option::Some(config) + }, + EntryFee::Extension(ext) => { + assert!(!ext.address.is_zero(), "EntryFee: Extension address cannot be zero"); + let src5 = ISRC5Dispatcher { contract_address: ext.address }; + let display_address: felt252 = ext.address.into(); + assert!( + src5.supports_interface(IENTRY_FEE_EXTENSION_ID), + "EntryFee: Extension {} does not support IEntryFeeExtension", + display_address, + ); + self._set_extension(context_id, ext); + Option::None + }, + } + } + + /// Internal: store entry fee config data + fn _set_entry_fee_config(ref self: TContractState, context_id: u64, config: @EntryFeeConfig) { + EntryFeeStoreTrait::set_entry_fee_config(ref self, context_id, config); + } + + /// Internal: persist the extension address and forward the config + /// to the extension contract. The config blob is NOT persisted here + /// — the extension is the sole source of truth for its own config, + /// and indexers wanting to recover it should read the original + /// extension call (e.g. via the host's `TournamentCreated`-style + /// event) or query the extension's own view methods. + fn _set_extension(ref self: TContractState, context_id: u64, ext: ExtensionConfig) { + EntryFeeStoreTrait::store_extension_address(ref self, context_id, ext.address); + + let dispatcher = IEntryFeeExtensionDispatcher { contract_address: ext.address }; + dispatcher.set_entry_fee_config(context_id, ext.config); + } + + /// Process entry fee deposit. + /// - EntryFeeDeposit::Config: transfers ERC20 tokens from caller to contract + /// - EntryFeeDeposit::Extension: calls pay_entry_fee on the extension with + /// caller-provided params + fn deposit_entry_fee(ref self: TContractState, context_id: u64, deposit: EntryFeeDeposit) { + match deposit { + EntryFeeDeposit::Config(config) => { + let erc20_dispatcher = IERC20Dispatcher { contract_address: config.token_address }; + assert!( + erc20_dispatcher + .transfer_from( + get_caller_address(), get_contract_address(), config.amount.into(), + ), + "EntryFee: ERC20 transfer_from failed", + ); + }, + EntryFeeDeposit::Extension(pay_params) => { + let extension_address = EntryFeeStoreTrait::get_extension(@self, context_id); + assert!(!extension_address.is_zero(), "EntryFee: No extension configured"); + let dispatcher = IEntryFeeExtensionDispatcher { + contract_address: extension_address, + }; + dispatcher.pay_entry_fee(context_id, pay_params); + }, + } + } + + /// Forward a payout call to the entry-fee extension configured for + /// `context_id`. Reverts if no extension was configured (the + /// built-in deposit/payout path is host-owned and does not flow + /// through this method). + /// + /// The host is a pure dispatcher: it passes the supplied + /// `(token_id, claim_params)` straight through. The extension + /// resolves recipient, eligibility, and dedupe entirely from its + /// own state. `token_id = Some(id)` typically signals a claim + /// (extension derives recipient via `owner_of(id)`); `None` + /// signals a non-claim flow (sponsor refund, creator share, + /// raffle) and the extension extracts whatever it needs from + /// `claim_params`. + /// + /// Hosts are responsible for any cross-cutting concerns + /// (finalization checks, reentrancy guards) before invoking this. + fn payout_entry_fee_extension( + ref self: TContractState, + context_id: u64, + token_id: Option, + claim_params: Span, + ) { + let extension_address = EntryFeeStoreTrait::get_extension(@self, context_id); + assert!(!extension_address.is_zero(), "EntryFee: No extension configured"); + let dispatcher = IEntryFeeExtensionDispatcher { contract_address: extension_address }; + dispatcher.payout_entry_fee(context_id, token_id, claim_params); + } + + /// Read-through dispatcher for `IEntryFeeExtension.get_config`. Lets + /// host viewers (frontends, indexer RPC fallbacks) surface the + /// original config blob for extension-fee contexts without needing + /// per-extension knowledge of the extension's internal storage. + /// Returns an empty span when the context has no extension fee. + fn get_entry_fee_extension_config( + self: @TContractState, context_owner: ContractAddress, context_id: u64, + ) -> Span { + let extension_address = EntryFeeStoreTrait::get_extension(self, context_id); + if extension_address.is_zero() { + return array![].span(); + } + let dispatcher = IEntryFeeExtensionDispatcher { contract_address: extension_address }; + dispatcher.get_config(context_owner, context_id) + } + + /// Payout to a recipient + fn payout( + ref self: TContractState, + token_address: ContractAddress, + recipient: ContractAddress, + amount: u128, + ) { + if amount > 0 { + let erc20_dispatcher = IERC20Dispatcher { contract_address: token_address }; + assert!( + erc20_dispatcher.transfer(recipient, amount.into()), + "EntryFee: ERC20 transfer failed", + ); + } + } + + /// Check if a claim has been made + fn is_claimed(self: @TContractState, context_id: u64, claim_type: EntryFeeClaimType) -> bool { + EntryFeeStoreTrait::is_claimed(self, context_id, claim_type) + } + + /// Mark a claim as completed + fn set_claimed(ref self: TContractState, context_id: u64, claim_type: EntryFeeClaimType) { + EntryFeeStoreTrait::set_claimed(ref self, context_id, claim_type); + } + + // --- Extension helpers --- + + /// Get extension address for a context (zero = no extension configured). + fn get_extension_address(self: @TContractState, context_id: u64) -> ContractAddress { + EntryFeeStoreTrait::get_extension(self, context_id) + } +} diff --git a/packages/metagame/src/entry_fee/entry_fee_component.cairo b/packages/metagame/src/entry_fee/entry_fee_component.cairo index 54017027..3c464961 100644 --- a/packages/metagame/src/entry_fee/entry_fee_component.cairo +++ b/packages/metagame/src/entry_fee/entry_fee_component.cairo @@ -8,25 +8,18 @@ #[starknet::component] pub mod EntryFeeComponent { - use core::num::traits::Zero; use game_components_interfaces::entry_fee::{IENTRY_FEE_ID, IEntryFee}; use game_components_utilities::distribution::packed_shares::CustomShares; use game_components_utilities::distribution::structs::{ PackedDistribution, PackedDistributionStorePacking, }; - use metagame_extensions_interfaces::entry_fee_extension::{ - IENTRY_FEE_EXTENSION_ID, IEntryFeeExtensionDispatcher, IEntryFeeExtensionDispatcherTrait, - }; use metagame_extensions_interfaces::extension::ExtensionConfig; - 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; + use starknet::ContractAddress; use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::entry_fee::entry_fee_store::{EntryFeeStoreImpl, EntryFeeStoreTrait}; use crate::entry_fee::store::Store; use crate::entry_fee::structs::{ AdditionalShare, EntryFee, EntryFeeClaimType, EntryFeeConfig, EntryFeeDeposit, @@ -207,7 +200,7 @@ pub mod EntryFeeComponent { fn get_entry_fee( self: @ComponentState, context_id: u64, ) -> Option { - EntryFeeStoreTrait::get_entry_fee(self, context_id) + crate::entry_fee::api::EntryFeeImpl::get_entry_fee(self, context_id) } } @@ -220,7 +213,7 @@ pub mod EntryFeeComponent { fn _get_entry_fee( self: @ComponentState, context_id: u64, ) -> Option { - EntryFeeStoreTrait::get_entry_fee(self, context_id) + crate::entry_fee::api::EntryFeeInternalImpl::_get_entry_fee(self, context_id) } /// Get additional shares for a context @@ -228,7 +221,7 @@ pub mod EntryFeeComponent { fn _get_additional_shares( self: @ComponentState, context_id: u64, ) -> Span { - EntryFeeStoreTrait::get_additional_shares(self, context_id) + crate::entry_fee::api::EntryFeeInternalImpl::_get_additional_shares(self, context_id) } /// Persist a custom distribution shares array for a context. @@ -237,7 +230,9 @@ pub mod EntryFeeComponent { fn _store_distribution_shares( ref self: ComponentState, context_id: u64, shares: Span, ) { - EntryFeeStoreTrait::store_distribution_shares(ref self, context_id, shares); + crate::entry_fee::api::EntryFeeInternalImpl::_store_distribution_shares( + ref self, context_id, shares, + ) } /// Read `count` custom distribution shares for a context. Callers @@ -247,7 +242,9 @@ pub mod EntryFeeComponent { fn _get_distribution_shares( self: @ComponentState, context_id: u64, count: u32, ) -> Array { - EntryFeeStoreTrait::get_distribution_shares(self, context_id, count) + crate::entry_fee::api::EntryFeeInternalImpl::_get_distribution_shares( + self, context_id, count, + ) } /// Read a single custom distribution share at a 1-indexed position. @@ -257,7 +254,9 @@ pub mod EntryFeeComponent { fn _get_custom_share_at( self: @ComponentState, context_id: u64, position: u32, ) -> u16 { - EntryFeeStoreTrait::get_custom_share_at(self, context_id, position) + crate::entry_fee::api::EntryFeeInternalImpl::_get_custom_share_at( + self, context_id, position, + ) } /// Set entry fee or extension for a context. @@ -267,38 +266,18 @@ pub mod EntryFeeComponent { fn set_entry_fee( ref self: ComponentState, context_id: u64, entry_fee: EntryFee, ) -> Option { - // Assert entry fee has not already been set (either config or extension) - assert!( - !EntryFeeStoreTrait::is_entry_fee_set(@self, context_id), - "EntryFee: Entry fee already set for context {}", - context_id, - ); - - match entry_fee { - EntryFee::Config(config) => { - EntryFeeStoreTrait::set_entry_fee_config(ref self, context_id, @config); - Option::Some(config) - }, - EntryFee::Extension(ext) => { - assert!(!ext.address.is_zero(), "EntryFee: Extension address cannot be zero"); - let src5 = ISRC5Dispatcher { contract_address: ext.address }; - let display_address: felt252 = ext.address.into(); - assert!( - src5.supports_interface(IENTRY_FEE_EXTENSION_ID), - "EntryFee: Extension {} does not support IEntryFeeExtension", - display_address, - ); - self._set_extension(context_id, ext); - Option::None - }, - } + crate::entry_fee::api::EntryFeeInternalImpl::set_entry_fee( + ref self, context_id, entry_fee, + ) } /// Internal: store entry fee config data fn _set_entry_fee_config( ref self: ComponentState, context_id: u64, config: @EntryFeeConfig, ) { - EntryFeeStoreTrait::set_entry_fee_config(ref self, context_id, config); + crate::entry_fee::api::EntryFeeInternalImpl::_set_entry_fee_config( + ref self, context_id, config, + ) } /// Internal: persist the extension address and forward the config @@ -310,10 +289,7 @@ pub mod EntryFeeComponent { fn _set_extension( ref self: ComponentState, context_id: u64, ext: ExtensionConfig, ) { - EntryFeeStoreTrait::store_extension_address(ref self, context_id, ext.address); - - let dispatcher = IEntryFeeExtensionDispatcher { contract_address: ext.address }; - dispatcher.set_entry_fee_config(context_id, ext.config); + crate::entry_fee::api::EntryFeeInternalImpl::_set_extension(ref self, context_id, ext) } /// Process entry fee deposit. @@ -323,28 +299,9 @@ pub mod EntryFeeComponent { fn deposit_entry_fee( ref self: ComponentState, context_id: u64, deposit: EntryFeeDeposit, ) { - match deposit { - EntryFeeDeposit::Config(config) => { - let erc20_dispatcher = IERC20Dispatcher { - contract_address: config.token_address, - }; - assert!( - erc20_dispatcher - .transfer_from( - get_caller_address(), get_contract_address(), config.amount.into(), - ), - "EntryFee: ERC20 transfer_from failed", - ); - }, - EntryFeeDeposit::Extension(pay_params) => { - let extension_address = EntryFeeStoreTrait::get_extension(@self, context_id); - assert!(!extension_address.is_zero(), "EntryFee: No extension configured"); - let dispatcher = IEntryFeeExtensionDispatcher { - contract_address: extension_address, - }; - dispatcher.pay_entry_fee(context_id, pay_params); - }, - } + crate::entry_fee::api::EntryFeeInternalImpl::deposit_entry_fee( + ref self, context_id, deposit, + ) } /// Forward a payout call to the entry-fee extension configured for @@ -369,10 +326,9 @@ pub mod EntryFeeComponent { token_id: Option, claim_params: Span, ) { - let extension_address = EntryFeeStoreTrait::get_extension(@self, context_id); - assert!(!extension_address.is_zero(), "EntryFee: No extension configured"); - let dispatcher = IEntryFeeExtensionDispatcher { contract_address: extension_address }; - dispatcher.payout_entry_fee(context_id, token_id, claim_params); + crate::entry_fee::api::EntryFeeInternalImpl::payout_entry_fee_extension( + ref self, context_id, token_id, claim_params, + ) } /// Read-through dispatcher for `IEntryFeeExtension.get_config`. Lets @@ -383,12 +339,9 @@ pub mod EntryFeeComponent { fn get_entry_fee_extension_config( self: @ComponentState, context_owner: ContractAddress, context_id: u64, ) -> Span { - let extension_address = EntryFeeStoreTrait::get_extension(self, context_id); - if extension_address.is_zero() { - return array![].span(); - } - let dispatcher = IEntryFeeExtensionDispatcher { contract_address: extension_address }; - dispatcher.get_config(context_owner, context_id) + crate::entry_fee::api::EntryFeeInternalImpl::get_entry_fee_extension_config( + self, context_owner, context_id, + ) } /// Payout to a recipient @@ -398,20 +351,16 @@ pub mod EntryFeeComponent { recipient: ContractAddress, amount: u128, ) { - if amount > 0 { - let erc20_dispatcher = IERC20Dispatcher { contract_address: token_address }; - assert!( - erc20_dispatcher.transfer(recipient, amount.into()), - "EntryFee: ERC20 transfer failed", - ); - } + crate::entry_fee::api::EntryFeeInternalImpl::payout( + ref self, token_address, recipient, amount, + ) } /// Check if a claim has been made fn is_claimed( self: @ComponentState, context_id: u64, claim_type: EntryFeeClaimType, ) -> bool { - EntryFeeStoreTrait::is_claimed(self, context_id, claim_type) + crate::entry_fee::api::EntryFeeInternalImpl::is_claimed(self, context_id, claim_type) } /// Mark a claim as completed @@ -420,7 +369,9 @@ pub mod EntryFeeComponent { context_id: u64, claim_type: EntryFeeClaimType, ) { - EntryFeeStoreTrait::set_claimed(ref self, context_id, claim_type); + crate::entry_fee::api::EntryFeeInternalImpl::set_claimed( + ref self, context_id, claim_type, + ) } // --- Extension helpers --- @@ -429,7 +380,7 @@ pub mod EntryFeeComponent { fn get_extension_address( self: @ComponentState, context_id: u64, ) -> ContractAddress { - EntryFeeStoreTrait::get_extension(self, context_id) + crate::entry_fee::api::EntryFeeInternalImpl::get_extension_address(self, context_id) } } diff --git a/packages/metagame/src/entry_fee/storage_adapter.cairo b/packages/metagame/src/entry_fee/storage_adapter.cairo new file mode 100644 index 00000000..3c3c448e --- /dev/null +++ b/packages/metagame/src/entry_fee/storage_adapter.cairo @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Optional adapter retaining the original component storage maps. +use game_components_utilities::distribution::packed_shares::CustomShares; +use game_components_utilities::distribution::structs::PackedDistribution; +use starknet::ContractAddress; +use crate::entry_fee::entry_fee_component::EntryFeeComponent; +use crate::entry_fee::store::Store; +use crate::entry_fee::structs::PackedAdditionalShares; + +pub impl ComponentStore, +Drop> of Store { + fn get_token(self: @T, context_id: u64) -> ContractAddress { + Store::get_token(EntryFeeComponent::HasComponent::get_component(self), context_id) + } + fn set_token(ref self: T, context_id: u64, token: ContractAddress) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_token(ref component, context_id, token); + } + fn get_data_raw(self: @T, context_id: u64) -> felt252 { + Store::get_data_raw(EntryFeeComponent::HasComponent::get_component(self), context_id) + } + fn set_data_raw(ref self: T, context_id: u64, data: felt252) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_data_raw(ref component, context_id, data); + } + fn get_additional_recipient(self: @T, context_id: u64, index: u8) -> ContractAddress { + Store::get_additional_recipient( + EntryFeeComponent::HasComponent::get_component(self), context_id, index, + ) + } + fn set_additional_recipient( + ref self: T, context_id: u64, index: u8, recipient: ContractAddress, + ) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_additional_recipient(ref component, context_id, index, recipient); + } + fn get_packed_shares(self: @T, context_id: u64, slot: u8) -> PackedAdditionalShares { + Store::get_packed_shares( + EntryFeeComponent::HasComponent::get_component(self), context_id, slot, + ) + } + fn set_packed_shares(ref self: T, context_id: u64, slot: u8, shares: PackedAdditionalShares) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_packed_shares(ref component, context_id, slot, shares); + } + fn get_refund_claimed(self: @T, context_id: u64, token_id: felt252) -> bool { + Store::get_refund_claimed( + EntryFeeComponent::HasComponent::get_component(self), context_id, token_id, + ) + } + fn set_refund_claimed(ref self: T, context_id: u64, token_id: felt252, claimed: bool) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_refund_claimed(ref component, context_id, token_id, claimed); + } + fn get_position_claimed(self: @T, context_id: u64, position: u32) -> bool { + Store::get_position_claimed( + EntryFeeComponent::HasComponent::get_component(self), context_id, position, + ) + } + fn set_position_claimed(ref self: T, context_id: u64, position: u32, claimed: bool) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_position_claimed(ref component, context_id, position, claimed); + } + fn get_extension_address(self: @T, context_id: u64) -> ContractAddress { + Store::get_extension_address( + EntryFeeComponent::HasComponent::get_component(self), context_id, + ) + } + fn set_extension_address(ref self: T, context_id: u64, address: ContractAddress) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_extension_address(ref component, context_id, address); + } + fn get_distribution_shares_packed(self: @T, context_id: u64, slot: u8) -> CustomShares { + Store::get_distribution_shares_packed( + EntryFeeComponent::HasComponent::get_component(self), context_id, slot, + ) + } + fn set_distribution_shares_packed( + ref self: T, context_id: u64, slot: u8, shares: CustomShares, + ) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_distribution_shares_packed(ref component, context_id, slot, shares); + } + fn get_distribution(self: @T, context_id: u64) -> PackedDistribution { + Store::get_distribution(EntryFeeComponent::HasComponent::get_component(self), context_id) + } + fn set_distribution(ref self: T, context_id: u64, distribution: PackedDistribution) { + let mut component = EntryFeeComponent::HasComponent::get_component_mut(ref self); + Store::set_distribution(ref component, context_id, distribution); + } +} diff --git a/packages/metagame/src/entry_fee/store.cairo b/packages/metagame/src/entry_fee/store.cairo index 3d1da26a..9b766cd1 100644 --- a/packages/metagame/src/entry_fee/store.cairo +++ b/packages/metagame/src/entry_fee/store.cairo @@ -6,7 +6,7 @@ use starknet::ContractAddress; use crate::entry_fee::structs::PackedAdditionalShares; /// Generic store trait for entry fee operations. -/// Maps storage reads/writes without business logic. +/// Logical accessors without business logic; hosts may pack, derive or share fields. pub trait Store { fn get_token(self: @T, context_id: u64) -> ContractAddress; fn set_token(ref self: T, context_id: u64, token: ContractAddress); diff --git a/packages/metagame/src/entry_fee/tests.cairo b/packages/metagame/src/entry_fee/tests.cairo index d6908995..88e2e09d 100644 --- a/packages/metagame/src/entry_fee/tests.cairo +++ b/packages/metagame/src/entry_fee/tests.cairo @@ -2,5 +2,6 @@ pub mod mocks; mod test_claim_types; mod test_distribution_shares; mod test_entry_fee_store; +mod test_host_storage; mod test_packed_additional_shares; mod test_storage_gas; diff --git a/packages/metagame/src/entry_fee/tests/test_host_storage.cairo b/packages/metagame/src/entry_fee/tests/test_host_storage.cairo new file mode 100644 index 00000000..dac258e2 --- /dev/null +++ b/packages/metagame/src/entry_fee/tests/test_host_storage.cairo @@ -0,0 +1,181 @@ +use game_components_interfaces::entry_fee::{IEntryFeeDispatcher, IEntryFeeDispatcherTrait}; +use game_components_interfaces::registration::Registration; +use game_components_utilities::distribution::structs::Distribution; +use metagame_extensions_interfaces::extension::ExtensionConfig; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare, mock_call}; +use starknet::ContractAddress; +use crate::entry_fee::structs::{ + AdditionalShare, EntryFee, EntryFeeClaimType, EntryFeeConfig, EntryFeeDeposit, +}; +use crate::registration::host_fixtures::{ + IHostRegistrationFeeDispatcher, IHostRegistrationFeeDispatcherTrait, +}; +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} +fn deploy(name: ByteArray) -> (IHostRegistrationFeeDispatcher, IEntryFeeDispatcher) { + let (contract_address, _) = declare(name).unwrap().contract_class().deploy(@array![]).unwrap(); + (IHostRegistrationFeeDispatcher { contract_address }, IEntryFeeDispatcher { contract_address }) +} +fn config() -> EntryFeeConfig { + EntryFeeConfig { + token_address: addr(0xabc), + amount: 1000, + game_creator_share: Option::Some(1000), + refund_share: Option::Some(500), + additional_shares: array![ + AdditionalShare { recipient: addr(11), share_bps: 300 }, + AdditionalShare { recipient: addr(12), share_bps: 200 }, + ] + .span(), + distribution: Option::Some(Distribution::Custom(array![4000, 6000].span())), + distribution_count: 2, + } +} +fn check_fee(name: ByteArray) { + let (host, views) = deploy(name); + assert!(views.get_entry_fee(1).is_none()); + assert!(host.get_entry_fee_extension_config(addr(1), 1).is_empty()); + let stored = host.set_entry_fee(1, EntryFee::Config(config())).unwrap(); + assert!(stored.amount == 1000); + let read = views.get_entry_fee(1).unwrap(); + assert!(read.amount == 1000); + assert!(read.game_creator_share == Option::Some(1000)); + assert!(read.refund_share == Option::Some(500)); + assert!(read.distribution_count == 2); + assert!(host._get_entry_fee(1).unwrap().amount == 1000); + assert!(*host._get_additional_shares(1).at(1).recipient == addr(12)); + assert!(*host._get_distribution_shares(1, 2).at(1) == 6000); + assert!(host._get_custom_share_at(1, 1) == 4000); + host.set_claimed(1, EntryFeeClaimType::GameCreator); + host.set_claimed(1, EntryFeeClaimType::AdditionalShare(1)); + host.set_claimed(1, EntryFeeClaimType::Position(2)); + assert!(host.is_claimed(1, EntryFeeClaimType::GameCreator)); + assert!(host.is_claimed(1, EntryFeeClaimType::AdditionalShare(1))); + assert!(!host.is_claimed(1, EntryFeeClaimType::AdditionalShare(0))); + assert!(host.is_claimed(1, EntryFeeClaimType::Position(2))); + assert!(!host.is_claimed(2, EntryFeeClaimType::Position(2))); + assert!(views.get_entry_fee(1).unwrap().amount == 1000); + assert!(host._get_custom_share_at(1, 2) == 6000); + // Both token transfers and extension dispatch use the same shared internal API. + mock_call(addr(0xabc), selector!("transfer_from"), true, 1); + host.deposit_entry_fee(1, EntryFeeDeposit::Config(config())); + mock_call(addr(0xabc), selector!("transfer"), true, 1); + host.payout(addr(0xabc), addr(99), 100); + host.payout(addr(0xabc), addr(99), 0); + host._store_distribution_shares(2, array![1, 2, 3].span()); + assert!(*host._get_distribution_shares(2, 3).at(2) == 3); +} +#[test] +fn entry_fee_host_storage_roundtrip_and_claims() { + check_fee("HostRegistrationFee"); +} +#[test] +fn entry_fee_host_storage_component_adapter() { + check_fee("AdapterRegistrationFee"); +} +fn check_entry_fee_extensions(name: ByteArray) { + let (host, views) = deploy(name); + let ext = addr(0xeee); + mock_call(ext, selector!("supports_interface"), true, 1); + mock_call(ext, selector!("set_entry_fee_config"), (), 1); + assert!( + host + .set_entry_fee( + 4, EntryFee::Extension(ExtensionConfig { address: ext, config: array![99].span() }), + ) + .is_none(), + ); + assert!(views.get_entry_fee(4).is_none()); + assert!(host.get_extension_address(4) == ext); + mock_call(ext, selector!("get_config"), array![99].span(), 1); + assert!(*host.get_entry_fee_extension_config(addr(1), 4).at(0) == 99); + mock_call(ext, selector!("pay_entry_fee"), (), 1); + host.deposit_entry_fee(4, EntryFeeDeposit::Extension(array![17].span())); + mock_call(ext, selector!("payout_entry_fee"), (), 1); + host.payout_entry_fee_extension(4, Option::Some(77), array![19].span()); +} +#[test] +#[should_panic(expected: "EntryFee: Entry fee already set for context 1")] +fn entry_fee_host_storage_rejects_reconfiguration() { + let (host, _) = deploy("HostRegistrationFee"); + let _ = host.set_entry_fee(1, EntryFee::Config(config())); + let _ = host.set_entry_fee(1, EntryFee::Config(config())); +} +#[test] +#[should_panic(expected: "EntryFee: Extension address cannot be zero")] +fn entry_fee_host_storage_rejects_zero_extension() { + let (host, _) = deploy("HostRegistrationFee"); + let _ = host + .set_entry_fee( + 1, EntryFee::Extension(ExtensionConfig { address: addr(0), config: array![].span() }), + ); +} +#[test] +#[should_panic(expected: "EntryFee: ERC20 transfer_from failed")] +fn entry_fee_host_storage_rejects_failed_deposit() { + let (host, _) = deploy("HostRegistrationFee"); + mock_call(addr(0xabc), selector!("transfer_from"), false, 1); + host.deposit_entry_fee(1, EntryFeeDeposit::Config(config())); +} +// Matched benchmark operations: deployment, registration, submission and fee refund marking. +fn benchmark(name: ByteArray) { + let (host, _) = deploy(name); + host + .set_entry( + Registration { + context_id: 1, + entry_id: 1, + game_token_id: 77, + has_submitted: false, + is_banned: false, + }, + ); + host.mark_token_submitted(1, 77); + host.set_claimed(1, EntryFeeClaimType::Refund(77)); +} +#[test] +fn entry_fee_host_storage_gas_combined() { + benchmark("HostRegistrationFee"); +} +#[test] +fn entry_fee_host_storage_gas_separate() { + benchmark("AdapterRegistrationFee"); +} + +#[test] +fn entry_fee_host_storage_extensions() { + check_entry_fee_extensions("HostRegistrationFee"); +} +#[test] +fn entry_fee_host_storage_extension_component_adapter() { + check_entry_fee_extensions("AdapterRegistrationFee"); +} + +#[test] +fn entry_fee_host_storage_scalar_distribution_ranges() { + let (host, views) = deploy("HostRegistrationFee"); + let distributions = array![ + Distribution::Linear(0xffff), Distribution::Exponential(0xffff), Distribution::Uniform, + Distribution::Geometric((255, 254)), + Distribution::Tiered( + game_components_utilities::distribution::structs::TieredConfig { + head_ratio: (255, 254), head_count: 255, head_share_bps: 9000, + }, + ), + ]; + let mut index = 0; + while index < distributions.len() { + let mut cfg = config(); + cfg.amount = 0xffffffffffffffffffffffffffffffff; + cfg.distribution = Option::Some(*distributions.at(index)); + cfg.distribution_count = 0xffffffff; + // This low-level setter is intentionally internal; hosts validate their supported terms. + host._set_entry_fee_config((index + 1).into(), cfg); + let actual = views.get_entry_fee((index + 1).into()).unwrap(); + assert!(actual.amount == 0xffffffffffffffffffffffffffffffff); + assert!(actual.distribution_count == 0xffffffff); + assert!(actual.distribution == Option::Some(*distributions.at(index))); + index += 1; + } +} diff --git a/packages/metagame/src/entry_requirement.cairo b/packages/metagame/src/entry_requirement.cairo index 6fd00aee..1559d814 100644 --- a/packages/metagame/src/entry_requirement.cairo +++ b/packages/metagame/src/entry_requirement.cairo @@ -1,6 +1,8 @@ +pub mod api; pub mod entry_requirement; pub mod entry_requirement_component; pub mod entry_requirement_store; +pub mod storage_adapter; pub mod store; pub mod structs; diff --git a/packages/metagame/src/entry_requirement/api.cairo b/packages/metagame/src/entry_requirement/api.cairo new file mode 100644 index 00000000..717dd78f --- /dev/null +++ b/packages/metagame/src/entry_requirement/api.cairo @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Storage-independent entry-requirement views and validated configuration writes. +use core::num::traits::Zero; +use game_components_interfaces::entry_requirement::{ + EntryRequirement, EntryRequirementType, IEntryRequirement, QualificationEntries, + QualificationProof, +}; +use metagame_extensions_interfaces::entry_requirement_extension::IENTRY_REQUIREMENT_EXTENSION_ID; +use openzeppelin_interfaces::erc721::IERC721_ID; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use crate::entry_requirement::entry_requirement_store::{ + EntryRequirementStoreImpl, EntryRequirementStoreTrait, +}; +use crate::entry_requirement::store::Store; + +#[starknet::embeddable] +pub impl EntryRequirementImpl< + TContractState, +Store, +Drop, +> of IEntryRequirement { + fn get_entry_requirement(self: @TContractState, context_id: u64) -> Option { + EntryRequirementStoreTrait::get_entry_requirement(self, context_id) + } + fn get_qualification_entries( + self: @TContractState, context_id: u64, proof: QualificationProof, + ) -> QualificationEntries { + EntryRequirementStoreTrait::get_qualification_entries(self, context_id, proof) + } +} + +#[generate_trait] +pub impl EntryRequirementInternalImpl, +Drop> of EntryRequirementInternalTrait { + /// Validate SRC5 support before persisting configuration. Internal only; + /// access control and extension add_config remain the host's responsibility. + fn set_entry_requirement(ref self: T, context_id: u64, requirement: Option) { + if let Option::Some(req) = requirement { + // Preserve the standard component setter's validation and revert messages. + match req.entry_requirement_type { + EntryRequirementType::token(token) => { + let display_address: felt252 = token.into(); + assert!( + ISRC5Dispatcher { contract_address: token }.supports_interface(IERC721_ID), + "EntryRequirement: Token {} does not support IERC721", + display_address, + ); + }, + EntryRequirementType::extension(config) => { + assert!( + !config.address.is_zero(), + "EntryRequirement: Extension address cannot be zero", + ); + let display_address: felt252 = config.address.into(); + assert!( + ISRC5Dispatcher { contract_address: config.address } + .supports_interface(IENTRY_REQUIREMENT_EXTENSION_ID), + "EntryRequirement: Extension {} does not support IEntryRequirementExtension", + display_address, + ); + }, + }; + } + EntryRequirementStoreTrait::set_entry_requirement(ref self, context_id, requirement); + } +} diff --git a/packages/metagame/src/entry_requirement/entry_requirement_component.cairo b/packages/metagame/src/entry_requirement/entry_requirement_component.cairo index a794b2c2..5bff6a59 100644 --- a/packages/metagame/src/entry_requirement/entry_requirement_component.cairo +++ b/packages/metagame/src/entry_requirement/entry_requirement_component.cairo @@ -22,11 +22,7 @@ #[starknet::component] pub mod EntryRequirementComponent { - use core::num::traits::Zero; use game_components_interfaces::entry_requirement::{IENTRY_REQUIREMENT_ID, IEntryRequirement}; - use metagame_extensions_interfaces::entry_requirement_extension::IENTRY_REQUIREMENT_EXTENSION_ID; - use openzeppelin_interfaces::erc721::IERC721_ID; - use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; use starknet::ContractAddress; @@ -40,7 +36,7 @@ pub mod EntryRequirementComponent { use crate::entry_requirement::store::Store; use crate::entry_requirement::structs::{ EntryRequirement, EntryRequirementMeta, EntryRequirementMetaStorePacking, - EntryRequirementType, QualificationEntries, QualificationProof, + QualificationEntries, QualificationProof, }; #[storage] @@ -119,13 +115,17 @@ pub mod EntryRequirementComponent { fn get_entry_requirement( self: @ComponentState, context_id: u64, ) -> Option { - self._get_entry_requirement(context_id) + crate::entry_requirement::api::EntryRequirementImpl::get_entry_requirement( + self, context_id, + ) } fn get_qualification_entries( self: @ComponentState, context_id: u64, proof: QualificationProof, ) -> QualificationEntries { - self._get_qualification_entries(context_id, proof) + crate::entry_requirement::api::EntryRequirementImpl::get_qualification_entries( + self, context_id, proof, + ) } } @@ -148,51 +148,9 @@ pub mod EntryRequirementComponent { context_id: u64, entry_requirement: Option, ) { - match entry_requirement { - Option::Some(req) => { - let (req_type, entry_limit) = match req.entry_requirement_type { - EntryRequirementType::token(token) => { - let src5 = ISRC5Dispatcher { contract_address: token }; - let display_address: felt252 = token.into(); - assert!( - src5.supports_interface(IERC721_ID), - "EntryRequirement: Token {} does not support IERC721", - display_address, - ); - self.set_token(context_id, token); - (entry_requirement::REQ_TYPE_TOKEN, req.entry_limit) - }, - EntryRequirementType::extension(config) => { - assert!( - !config.address.is_zero(), - "EntryRequirement: Extension address cannot be zero", - ); - let src5 = ISRC5Dispatcher { contract_address: config.address }; - let display_address: felt252 = config.address.into(); - assert!( - src5.supports_interface(IENTRY_REQUIREMENT_EXTENSION_ID), - "EntryRequirement: Extension {} does not support IEntryRequirementExtension", - display_address, - ); - // Address-only persistence: the extension contract - // owns its own config (the host typically forwards - // it directly via `add_config`). - self.set_extension_address(context_id, config.address); - (entry_requirement::REQ_TYPE_EXTENSION, req.entry_limit) - }, - }; - - let meta = EntryRequirementMeta { entry_limit, req_type }; - self.set_meta(context_id, meta); - }, - Option::None => { - // Write empty meta with REQ_TYPE_NONE - let meta = EntryRequirementMeta { - entry_limit: 0, req_type: entry_requirement::REQ_TYPE_NONE, - }; - self.set_meta(context_id, meta); - }, - } + crate::entry_requirement::api::EntryRequirementInternalImpl::set_entry_requirement( + ref self, context_id, entry_requirement, + ) } /// Get qualification entries for a context and qualification proof (internal) diff --git a/packages/metagame/src/entry_requirement/storage_adapter.cairo b/packages/metagame/src/entry_requirement/storage_adapter.cairo new file mode 100644 index 00000000..1397d649 --- /dev/null +++ b/packages/metagame/src/entry_requirement/storage_adapter.cairo @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Optional adapter retaining the standard component storage maps. +use starknet::ContractAddress; +use crate::entry_requirement::entry_requirement_component::EntryRequirementComponent; +use crate::entry_requirement::store::Store; +use crate::entry_requirement::structs::EntryRequirementMeta; + +/// The host chooses where requirement metadata is stored. +pub trait Metadata { + fn requirement_meta(self: @T, context_id: u64) -> EntryRequirementMeta; + fn set_requirement_meta(ref self: T, context_id: u64, meta: EntryRequirementMeta); +} + +pub impl ComponentStore< + T, +EntryRequirementComponent::HasComponent, impl Meta: Metadata, +Drop, +> of Store { + fn get_meta(self: @T, context_id: u64) -> EntryRequirementMeta { + Meta::requirement_meta(self, context_id) + } + fn set_meta(ref self: T, context_id: u64, meta: EntryRequirementMeta) { + Meta::set_requirement_meta(ref self, context_id, meta) + } + fn get_token(self: @T, context_id: u64) -> ContractAddress { + Store::get_token(EntryRequirementComponent::HasComponent::get_component(self), context_id) + } + fn set_token(ref self: T, context_id: u64, token: ContractAddress) { + let mut component = EntryRequirementComponent::HasComponent::get_component_mut(ref self); + Store::set_token(ref component, context_id, token); + } + fn get_extension_address(self: @T, context_id: u64) -> ContractAddress { + Store::get_extension_address( + EntryRequirementComponent::HasComponent::get_component(self), context_id, + ) + } + fn set_extension_address(ref self: T, context_id: u64, address: ContractAddress) { + let mut component = EntryRequirementComponent::HasComponent::get_component_mut(ref self); + Store::set_extension_address(ref component, context_id, address); + } + fn get_qualification_entries(self: @T, context_id: u64, hash: felt252) -> u32 { + Store::get_qualification_entries( + EntryRequirementComponent::HasComponent::get_component(self), context_id, hash, + ) + } + fn set_qualification_entries(ref self: T, context_id: u64, hash: felt252, count: u32) { + let mut component = EntryRequirementComponent::HasComponent::get_component_mut(ref self); + Store::set_qualification_entries(ref component, context_id, hash, count); + } +} + +/// Original metadata storage for applications that do not customize it. +pub impl ComponentMetadata< + T, +EntryRequirementComponent::HasComponent, +Drop, +> of Metadata { + fn requirement_meta(self: @T, context_id: u64) -> EntryRequirementMeta { + Store::get_meta(EntryRequirementComponent::HasComponent::get_component(self), context_id) + } + fn set_requirement_meta(ref self: T, context_id: u64, meta: EntryRequirementMeta) { + let mut component = EntryRequirementComponent::HasComponent::get_component_mut(ref self); + Store::set_meta(ref component, context_id, meta); + } +} diff --git a/packages/metagame/src/entry_requirement/tests.cairo b/packages/metagame/src/entry_requirement/tests.cairo index 2d86433c..81fec756 100644 --- a/packages/metagame/src/entry_requirement/tests.cairo +++ b/packages/metagame/src/entry_requirement/tests.cairo @@ -1,3 +1,5 @@ pub mod mocks; mod test_entry_requirement_component; + +mod test_host_storage; mod test_models; diff --git a/packages/metagame/src/entry_requirement/tests/test_host_storage.cairo b/packages/metagame/src/entry_requirement/tests/test_host_storage.cairo new file mode 100644 index 00000000..c54ca918 --- /dev/null +++ b/packages/metagame/src/entry_requirement/tests/test_host_storage.cairo @@ -0,0 +1,303 @@ +use game_components_interfaces::entry_requirement::{ + IEntryRequirementDispatcher, IEntryRequirementDispatcherTrait, +}; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; +use starknet::ContractAddress; +use crate::entry_requirement::structs::{ + EntryRequirement, EntryRequirementType, NFTQualification, QualificationProof, +}; + +#[starknet::interface] +trait IHostRequirement { + fn configure(ref self: T, id: u64, requirement: Option); + fn enter( + ref self: T, id: u64, proof: QualificationProof, qualifier: Option, + ) -> ContractAddress; +} +#[starknet::interface] +trait INftSetup { + fn set_owner(ref self: T, token_id: u256, owner: ContractAddress); +} + +#[starknet::contract] +mod HostRequirement { + use starknet::ContractAddress; + use starknet::storage::{Map, StorageMapReadAccess, StorageMapWriteAccess}; + use crate::entry_requirement::api::{ + EntryRequirementInternalImpl, EntryRequirementInternalTrait, + }; + use crate::entry_requirement::entry_requirement_store::{ + EntryRequirementStoreImpl, EntryRequirementStoreTrait, + }; + use crate::entry_requirement::store::Store; + use crate::entry_requirement::structs::{ + EntryRequirement, EntryRequirementMeta, QualificationProof, + }; + #[storage] + struct Storage { + custom_config: Map, + nft: Map, + validator: Map, + uses: Map<(u64, felt252), u32>, + } + #[abi(embed_v0)] + impl Views = crate::entry_requirement::api::EntryRequirementImpl; + impl HostStore of Store { + fn get_meta(self: @ContractState, context_id: u64) -> EntryRequirementMeta { + let packed = self.custom_config.read(context_id); + EntryRequirementMeta { + entry_limit: (packed / 0x100).try_into().unwrap(), + req_type: (packed & 0xff).try_into().unwrap(), + } + } + fn set_meta(ref self: ContractState, context_id: u64, meta: EntryRequirementMeta) { + self + .custom_config + .write( + context_id, + Into::::into(meta.entry_limit) * 0x100 + meta.req_type.into(), + ); + } + fn get_token(self: @ContractState, context_id: u64) -> ContractAddress { + self.nft.read(context_id) + } + fn set_token(ref self: ContractState, context_id: u64, token: ContractAddress) { + self.nft.write(context_id, token); + } + fn get_extension_address(self: @ContractState, context_id: u64) -> ContractAddress { + self.validator.read(context_id) + } + fn set_extension_address( + ref self: ContractState, context_id: u64, address: ContractAddress, + ) { + self.validator.write(context_id, address); + } + fn get_qualification_entries(self: @ContractState, context_id: u64, hash: felt252) -> u32 { + self.uses.read((context_id, hash)) + } + fn set_qualification_entries( + ref self: ContractState, context_id: u64, hash: felt252, count: u32, + ) { + self.uses.write((context_id, hash), count); + } + } + #[abi(embed_v0)] + impl TestApi of super::IHostRequirement { + fn configure(ref self: ContractState, id: u64, requirement: Option) { + EntryRequirementInternalTrait::set_entry_requirement(ref self, id, requirement); + } + fn enter( + ref self: ContractState, + id: u64, + proof: QualificationProof, + qualifier: Option, + ) -> ContractAddress { + let req = EntryRequirementStoreTrait::get_entry_requirement(@self, id).unwrap(); + let player = EntryRequirementStoreTrait::validate_qualification( + @self, id, req, proof, qualifier, + ); + EntryRequirementStoreTrait::update_qualification_entries(ref self, id, proof, req); + player + } + } +} +fn deploy() -> (IHostRequirementDispatcher, IEntryRequirementDispatcher, ContractAddress) { + let cls = declare("HostRequirement").unwrap().contract_class(); + let (address, _) = cls.deploy(@array![]).unwrap(); + let nft_cls = declare("ERC721Mock").unwrap().contract_class(); + let (nft, _) = nft_cls.deploy(@array![]).unwrap(); + ( + IHostRequirementDispatcher { contract_address: address }, + IEntryRequirementDispatcher { contract_address: address }, + nft, + ) +} +#[test] +fn entry_requirement_host_storage_validates_and_tracks_contexts() { + let (host, views, nft) = deploy(); + let player: ContractAddress = 0x123.try_into().unwrap(); + let proof = QualificationProof::NFT(NFTQualification { token_id: 1 }); + INftSetupDispatcher { contract_address: nft }.set_owner(1, player); + assert!(views.get_entry_requirement(1).is_none()); + let req = EntryRequirement { + entry_limit: 2, entry_requirement_type: EntryRequirementType::token(nft), + }; + host.configure(1, Option::Some(req)); + assert!(views.get_entry_requirement(1).unwrap() == req); + assert!(host.enter(1, proof, Option::Some(player)) == player); + assert!(host.enter(1, proof, Option::None) == player); + assert!(views.get_qualification_entries(1, proof).entry_count == 2); + assert!(views.get_qualification_entries(2, proof).entry_count == 0); + host.configure(2, Option::Some(req)); + assert!(host.enter(2, proof, Option::None) == player); + host.configure(1, Option::None); + assert!(views.get_entry_requirement(1).is_none()); + assert!( + views.get_qualification_entries(1, proof).entry_count == 2, + "configuration does not erase qualification history", + ); +} +#[test] +#[should_panic(expected: "EntryRequirement: Maximum qualified entries reached for context 1")] +fn entry_requirement_host_storage_enforces_limit() { + let (host, _, nft) = deploy(); + let player: ContractAddress = 0x123.try_into().unwrap(); + INftSetupDispatcher { contract_address: nft }.set_owner(1, player); + host + .configure( + 1, + Option::Some( + EntryRequirement { + entry_limit: 1, entry_requirement_type: EntryRequirementType::token(nft), + }, + ), + ); + let proof = QualificationProof::NFT(NFTQualification { token_id: 1 }); + host.enter(1, proof, Option::None); + host.enter(1, proof, Option::None); +} +#[test] +#[should_panic(expected: "EntryRequirement: claimed qualifier does not own the gating NFT")] +fn entry_requirement_host_storage_rejects_wrong_owner() { + let (host, _, nft) = deploy(); + let player: ContractAddress = 0x123.try_into().unwrap(); + INftSetupDispatcher { contract_address: nft }.set_owner(1, player); + host + .configure( + 1, + Option::Some( + EntryRequirement { + entry_limit: 1, entry_requirement_type: EntryRequirementType::token(nft), + }, + ), + ); + host + .enter( + 1, + QualificationProof::NFT(NFTQualification { token_id: 1 }), + Option::Some(0x456.try_into().unwrap()), + ); +} + +#[starknet::contract] +mod ComponentBackedRequirement { + use starknet::ContractAddress; + use crate::entry_requirement::api::{ + EntryRequirementInternalImpl, EntryRequirementInternalTrait, + }; + use crate::entry_requirement::entry_requirement_component::EntryRequirementComponent; + use crate::entry_requirement::entry_requirement_store::{ + EntryRequirementStoreImpl, EntryRequirementStoreTrait, + }; + use crate::entry_requirement::structs::{EntryRequirement, QualificationProof}; + component!( + path: EntryRequirementComponent, storage: entry_requirement, event: RequirementEvent, + ); + impl Meta = crate::entry_requirement::storage_adapter::ComponentMetadata; + impl StorageAdapter = + crate::entry_requirement::storage_adapter::ComponentStore; + #[abi(embed_v0)] + impl Views = crate::entry_requirement::api::EntryRequirementImpl; + #[storage] + struct Storage { + #[substorage(v0)] + entry_requirement: EntryRequirementComponent::Storage, + } + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + RequirementEvent: EntryRequirementComponent::Event, + } + #[abi(embed_v0)] + impl TestApi of super::IHostRequirement { + fn configure(ref self: ContractState, id: u64, requirement: Option) { + EntryRequirementInternalTrait::set_entry_requirement(ref self, id, requirement); + } + fn enter( + ref self: ContractState, + id: u64, + proof: QualificationProof, + qualifier: Option, + ) -> ContractAddress { + let req = EntryRequirementStoreTrait::get_entry_requirement(@self, id).unwrap(); + let player = EntryRequirementStoreTrait::validate_qualification( + @self, id, req, proof, qualifier, + ); + EntryRequirementStoreTrait::update_qualification_entries(ref self, id, proof, req); + player + } + } +} +#[test] +fn entry_requirement_host_storage_component_adapter_preserves_component_maps() { + let cls = declare("ComponentBackedRequirement").unwrap().contract_class(); + let (address, _) = cls.deploy(@array![]).unwrap(); + let nft_cls = declare("ERC721Mock").unwrap().contract_class(); + let (nft, _) = nft_cls.deploy(@array![]).unwrap(); + let host = IHostRequirementDispatcher { contract_address: address }; + let view = IEntryRequirementDispatcher { contract_address: address }; + let player: ContractAddress = 0x123.try_into().unwrap(); + INftSetupDispatcher { contract_address: nft }.set_owner(1, player); + let proof = QualificationProof::NFT(NFTQualification { token_id: 1 }); + let req = EntryRequirement { + entry_limit: 2, entry_requirement_type: EntryRequirementType::token(nft), + }; + host.configure(1, Option::Some(req)); + assert!(view.get_entry_requirement(1).unwrap() == req); + assert!(host.enter(1, proof, Option::Some(player)) == player); + assert!(view.get_qualification_entries(1, proof).entry_count == 1); + host.configure(1, Option::None); + assert!(view.get_entry_requirement(1).is_none()); +} + +#[test] +fn entry_requirement_host_storage_extension_configuration_and_dispatch() { + let cls = declare("ComponentBackedRequirement").unwrap().contract_class(); + let (address, _) = cls.deploy(@array![]).unwrap(); + let host = IHostRequirementDispatcher { contract_address: address }; + let view = IEntryRequirementDispatcher { contract_address: address }; + let extension: ContractAddress = 0x456.try_into().unwrap(); + let player: ContractAddress = 0x123.try_into().unwrap(); + snforge_std::start_mock_call(extension, selector!("supports_interface"), true); + snforge_std::start_mock_call(extension, selector!("valid_entry"), true); + let req = EntryRequirement { + entry_limit: 1, + entry_requirement_type: EntryRequirementType::extension( + crate::entry_requirement::structs::ExtensionConfig { + address: extension, config: array![17].span(), + }, + ), + }; + host.configure(1, Option::Some(req)); + let stored = view.get_entry_requirement(1).unwrap(); + match stored.entry_requirement_type { + EntryRequirementType::extension(cfg) => { + assert!(cfg.address == extension && cfg.config.is_empty()); + }, + _ => panic!("extension expected"), + } + let proof = QualificationProof::Extension(array![99].span()); + assert!(host.enter(1, proof, Option::Some(player)) == player); + assert!( + view.get_qualification_entries(1, proof).entry_count == 0, "extension owns quota tracking", + ); +} +#[test] +#[should_panic(expected: "EntryRequirement: Extension address cannot be zero")] +fn entry_requirement_host_storage_rejects_invalid_configuration() { + let (host, _, _) = deploy(); + host + .configure( + 1, + Option::Some( + EntryRequirement { + entry_limit: 1, + entry_requirement_type: EntryRequirementType::extension( + crate::entry_requirement::structs::ExtensionConfig { + address: 0.try_into().unwrap(), config: array![].span(), + }, + ), + }, + ), + ); +} diff --git a/packages/metagame/src/leaderboard.cairo b/packages/metagame/src/leaderboard.cairo index 84fab1e5..d4186226 100644 --- a/packages/metagame/src/leaderboard.cairo +++ b/packages/metagame/src/leaderboard.cairo @@ -1,9 +1,12 @@ +pub mod api; +pub mod hooks; // SPDX-License-Identifier: BUSL-1.1 pub mod interface; pub mod leaderboard; pub mod leaderboard_component; pub mod leaderboard_store; +pub mod storage_adapter; pub mod store; pub mod structs; diff --git a/packages/metagame/src/leaderboard/api.cairo b/packages/metagame/src/leaderboard/api.cairo new file mode 100644 index 00000000..ea1d8592 --- /dev/null +++ b/packages/metagame/src/leaderboard/api.cairo @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Storage-independent leaderboard API. Hosts implement Store and Configuration; +//! the standard component remains an optional storage implementation. +use core::num::traits::Zero; +use game_components_interfaces::leaderboard::{ + IGameDetailsDispatcher, IGameDetailsDispatcherTrait, ILeaderboard, LeaderboardEntry, + LeaderboardResult, LeaderboardStoreConfig, +}; +use starknet::ContractAddress; +use crate::leaderboard::hooks::LeaderboardHooksTrait; +use crate::leaderboard::leaderboard_store::{ + LeaderboardStoreHelpersImpl, LeaderboardStoreHelpersTrait, LeaderboardStoreImpl, + LeaderboardStoreTrait, +}; +use crate::leaderboard::store::Store; + +pub trait Configuration { + fn leaderboard_config(self: @T, context_id: u64) -> LeaderboardStoreConfig; + /// Override when reading the full configuration would fetch unused storage slots. + fn leaderboard_game_address( + self: @T, context_id: u64, + ) -> ContractAddress { + Self::leaderboard_config(self, context_id).game_address + } + fn leaderboard_max_entries( + self: @T, context_id: u64, + ) -> u32 { + Self::leaderboard_config(self, context_id).max_entries + } +} + +#[starknet::embeddable] +pub impl LeaderboardImpl< + TContractState, +Store, +Configuration, +Drop, +> of ILeaderboard { + fn get_config(self: @TContractState, context_id: u64) -> LeaderboardStoreConfig { + self.leaderboard_config(context_id) + } + fn get_leaderboard_entries(self: @TContractState, context_id: u64) -> Array { + LeaderboardStoreTrait::get_entries( + self, context_id, self.leaderboard_game_address(context_id), + ) + } + fn get_leaderboard_entry( + self: @TContractState, context_id: u64, position: u32, + ) -> LeaderboardEntry { + assert!(position > 0, "Leaderboard: position must be 1-indexed"); + assert!( + position <= self.get_count(context_id), + "Leaderboard: position {} out of range", + position, + ); + let index = position - 1; + let token_id = self.get_entry_at(context_id, index); + let game_address = self.leaderboard_game_address(context_id); + let score = if !game_address.is_zero() { + IGameDetailsDispatcher { contract_address: game_address }.score(token_id) + } else { + self.get_score_at(context_id, index) + }; + LeaderboardEntry { id: token_id, score } + } + fn get_top_leaderboard_entries( + self: @TContractState, context_id: u64, count: u32, + ) -> Array { + LeaderboardStoreHelpersTrait::get_range( + self, context_id, 0, count, self.leaderboard_game_address(context_id), + ) + } + fn get_position(self: @TContractState, context_id: u64, token_id: felt252) -> Option { + LeaderboardStoreTrait::get_position(self, context_id, token_id) + } + fn qualifies(self: @TContractState, context_id: u64, score: u64) -> bool { + LeaderboardStoreTrait::qualifies( + self, context_id, score, self.leaderboard_config(context_id), + ) + } + fn is_full(self: @TContractState, context_id: u64) -> bool { + LeaderboardStoreHelpersTrait::is_full( + self, context_id, self.leaderboard_max_entries(context_id), + ) + } + fn get_leaderboard_length(self: @TContractState, context_id: u64) -> u32 { + self.get_count(context_id) + } + fn find_position( + self: @TContractState, context_id: u64, score: u64, token_id: felt252, + ) -> Option { + LeaderboardStoreHelpersTrait::find_position( + self, context_id, score, token_id, self.leaderboard_config(context_id), + ) + } +} + +/// Internal only: the host must validate authorization, eligibility and phase. +#[generate_trait] +pub impl LeaderboardInternalImpl< + T, +Store, +Configuration, +LeaderboardHooksTrait, +Drop, +> of LeaderboardInternalTrait { + fn submit_score( + ref self: T, context_id: u64, token_id: felt252, score: u64, position: u32, + ) -> LeaderboardResult { + let config = self.leaderboard_config(context_id); + let result = LeaderboardStoreTrait::submit_score( + ref self, context_id, token_id, score, position, config, + ); + if result == LeaderboardResult::Success { + LeaderboardHooksTrait::on_score_submitted( + ref self, context_id, token_id, score, position, + ); + } + result + } +} diff --git a/packages/metagame/src/leaderboard/hooks.cairo b/packages/metagame/src/leaderboard/hooks.cairo new file mode 100644 index 00000000..5d9ff6c5 --- /dev/null +++ b/packages/metagame/src/leaderboard/hooks.cairo @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: BUSL-1.1 +use starknet::ContractAddress; + +pub trait LeaderboardHooksTrait { + /// Called after a score is successfully submitted + fn on_score_submitted( + ref self: TContractState, context_id: u64, token_id: felt252, score: u64, position: u32, + ); + + /// Called after a leaderboard context is configured + fn on_configured( + ref self: TContractState, + context_id: u64, + max_entries: u32, + ascending: bool, + game_address: ContractAddress, + ); + + /// Called after a leaderboard is cleared + fn on_cleared(ref self: TContractState, context_id: u64); + + /// Called after ownership is transferred + fn on_ownership_transferred( + ref self: TContractState, previous_owner: ContractAddress, new_owner: ContractAddress, + ); +} diff --git a/packages/metagame/src/leaderboard/leaderboard_component.cairo b/packages/metagame/src/leaderboard/leaderboard_component.cairo index 71fafb61..ed033072 100644 --- a/packages/metagame/src/leaderboard/leaderboard_component.cairo +++ b/packages/metagame/src/leaderboard/leaderboard_component.cairo @@ -17,10 +17,6 @@ pub mod LeaderboardComponent { StoragePointerWriteAccess, }; use starknet::{ContractAddress, get_caller_address}; - use crate::leaderboard::leaderboard_store::{ - IGameDetailsDispatcher, IGameDetailsDispatcherTrait, LeaderboardStoreHelpersImpl, - LeaderboardStoreHelpersTrait, LeaderboardStoreImpl, LeaderboardStoreTrait, - }; use crate::leaderboard::store::Store; #[storage] @@ -47,29 +43,7 @@ pub mod LeaderboardComponent { // Allows the embedding contract to define custom behavior on leaderboard // operations. Implementers can emit events, update state, etc. - pub trait LeaderboardHooksTrait { - /// Called after a score is successfully submitted - fn on_score_submitted( - ref self: TContractState, context_id: u64, token_id: felt252, score: u64, position: u32, - ); - - /// Called after a leaderboard context is configured - fn on_configured( - ref self: TContractState, - context_id: u64, - max_entries: u32, - ascending: bool, - game_address: ContractAddress, - ); - - /// Called after a leaderboard is cleared - fn on_cleared(ref self: TContractState, context_id: u64); - - /// Called after ownership is transferred - fn on_ownership_transferred( - ref self: TContractState, previous_owner: ContractAddress, new_owner: ContractAddress, - ); - } + pub use crate::leaderboard::hooks::LeaderboardHooksTrait; // Implement the Store trait for this component impl ComponentStore< @@ -146,6 +120,65 @@ pub mod LeaderboardComponent { } } + impl StoredConfiguration< + T, +HasComponent, + > of crate::leaderboard::api::Configuration> { + fn leaderboard_config(self: @ComponentState, context_id: u64) -> LeaderboardStoreConfig { + LeaderboardStoreConfig { + max_entries: self.max_entries.read(context_id), + ascending: self.ascending.read(context_id), + game_address: self.game_address.read(context_id), + } + } + fn leaderboard_game_address(self: @ComponentState, context_id: u64) -> ContractAddress { + self.game_address.read(context_id) + } + fn leaderboard_max_entries(self: @ComponentState, context_id: u64) -> u32 { + self.max_entries.read(context_id) + } + } + + impl ComponentHooks< + T, +HasComponent, +LeaderboardHooksTrait, +Drop, + > of LeaderboardHooksTrait> { + fn on_score_submitted( + ref self: ComponentState, + context_id: u64, + token_id: felt252, + score: u64, + position: u32, + ) { + let mut host = self.get_contract_mut(); + LeaderboardHooksTrait::on_score_submitted( + ref host, context_id, token_id, score, position, + ); + } + fn on_configured( + ref self: ComponentState, + context_id: u64, + max_entries: u32, + ascending: bool, + game_address: ContractAddress, + ) { + let mut host = self.get_contract_mut(); + LeaderboardHooksTrait::on_configured( + ref host, context_id, max_entries, ascending, game_address, + ); + } + fn on_cleared(ref self: ComponentState, context_id: u64) { + let mut host = self.get_contract_mut(); + LeaderboardHooksTrait::on_cleared(ref host, context_id); + } + fn on_ownership_transferred( + ref self: ComponentState, + previous_owner: ContractAddress, + new_owner: ContractAddress, + ) { + let mut host = self.get_contract_mut(); + LeaderboardHooksTrait::on_ownership_transferred(ref host, previous_owner, new_owner); + } + } + #[embeddable_as(LeaderboardImpl)] impl LeaderboardComponent< TContractState, @@ -157,96 +190,55 @@ pub mod LeaderboardComponent { fn get_leaderboard_entries( self: @ComponentState, context_id: u64, ) -> Array { - let game_address = self.game_address.read(context_id); - LeaderboardStoreTrait::get_entries(self, context_id, game_address) + crate::leaderboard::api::LeaderboardImpl::get_leaderboard_entries(self, context_id) } fn get_leaderboard_entry( self: @ComponentState, context_id: u64, position: u32, ) -> LeaderboardEntry { - assert!(position > 0, "Leaderboard: position must be 1-indexed"); - let count = self.entries_count.read(context_id); - assert!(position <= count, "Leaderboard: position {} out of range", position); - let storage_index: u32 = position - 1; - let token_id = self.entries.read((context_id, storage_index)); - let game_address = self.game_address.read(context_id); - let score = if !game_address.is_zero() { - // Live score read from the game contract. - IGameDetailsDispatcher { contract_address: game_address }.score(token_id) - } else { - self.scores.read((context_id, storage_index)) - }; - LeaderboardEntry { id: token_id, score } + crate::leaderboard::api::LeaderboardImpl::get_leaderboard_entry( + self, context_id, position, + ) } fn get_top_leaderboard_entries( self: @ComponentState, context_id: u64, count: u32, ) -> Array { - let game_address = self.game_address.read(context_id); - let total = self.entries_count.read(context_id); - let limit = if count < total { - count - } else { - total - }; - let mut entries = ArrayTrait::new(); - let mut i = 0_u32; - while i < limit { - let token_id = self.entries.read((context_id, i)); - let score = if !game_address.is_zero() { - IGameDetailsDispatcher { contract_address: game_address }.score(token_id) - } else { - self.scores.read((context_id, i)) - }; - entries.append(LeaderboardEntry { id: token_id, score }); - i += 1; - } - entries + crate::leaderboard::api::LeaderboardImpl::get_top_leaderboard_entries( + self, context_id, count, + ) } fn get_position( self: @ComponentState, context_id: u64, token_id: felt252, ) -> Option { - LeaderboardStoreTrait::get_position(self, context_id, token_id) + crate::leaderboard::api::LeaderboardImpl::get_position(self, context_id, token_id) } fn qualifies(self: @ComponentState, context_id: u64, score: u64) -> bool { - let config = LeaderboardStoreConfig { - max_entries: self.max_entries.read(context_id), - ascending: self.ascending.read(context_id), - game_address: self.game_address.read(context_id), - }; - LeaderboardStoreTrait::qualifies(self, context_id, score, config) + crate::leaderboard::api::LeaderboardImpl::qualifies(self, context_id, score) } fn is_full(self: @ComponentState, context_id: u64) -> bool { - let max_entries = self.max_entries.read(context_id); - LeaderboardStoreHelpersTrait::is_full(self, context_id, max_entries) + crate::leaderboard::api::LeaderboardImpl::is_full(self, context_id) } fn get_leaderboard_length(self: @ComponentState, context_id: u64) -> u32 { - self.entries_count.read(context_id) + crate::leaderboard::api::LeaderboardImpl::get_leaderboard_length(self, context_id) } fn get_config( self: @ComponentState, context_id: u64, ) -> LeaderboardStoreConfig { - LeaderboardStoreConfig { - max_entries: self.max_entries.read(context_id), - ascending: self.ascending.read(context_id), - game_address: self.game_address.read(context_id), - } + crate::leaderboard::api::LeaderboardImpl::get_config(self, context_id) } fn find_position( self: @ComponentState, context_id: u64, score: u64, token_id: felt252, ) -> Option { - let config = LeaderboardStoreConfig { - max_entries: self.max_entries.read(context_id), - ascending: self.ascending.read(context_id), - game_address: self.game_address.read(context_id), - }; - LeaderboardStoreHelpersTrait::find_position(self, context_id, score, token_id, config) + crate::leaderboard::api::LeaderboardImpl::find_position( + self, context_id, score, token_id, + ) } } @@ -373,27 +365,9 @@ pub mod LeaderboardComponent { score: u64, position: u32, ) -> LeaderboardResult { - let config = LeaderboardStoreConfig { - max_entries: self.max_entries.read(context_id), - ascending: self.ascending.read(context_id), - game_address: self.game_address.read(context_id), - }; - - let result = LeaderboardStoreTrait::submit_score( - ref self, context_id, token_id, score, position, config, - ); - - match result { - LeaderboardResult::Success => { - let mut contract = self.get_contract_mut(); - LeaderboardHooksTrait::on_score_submitted( - ref contract, context_id, token_id, score, position, - ); - }, - _ => {}, - } - - result + crate::leaderboard::api::LeaderboardInternalImpl::submit_score( + ref self, context_id, token_id, score, position, + ) } } diff --git a/packages/metagame/src/leaderboard/storage_adapter.cairo b/packages/metagame/src/leaderboard/storage_adapter.cairo new file mode 100644 index 00000000..5e996cc7 --- /dev/null +++ b/packages/metagame/src/leaderboard/storage_adapter.cairo @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Optional adapter retaining the standard component storage maps. +use crate::leaderboard::leaderboard_component::LeaderboardComponent; +use crate::leaderboard::store::Store; + +pub impl ComponentStore, +Drop> of Store { + fn get_leaderboard(self: @T, context_id: u64) -> Span { + Store::get_leaderboard(LeaderboardComponent::HasComponent::get_component(self), context_id) + } + fn get_count(self: @T, context_id: u64) -> u32 { + Store::get_count(LeaderboardComponent::HasComponent::get_component(self), context_id) + } + fn set_count(ref self: T, context_id: u64, count: u32) { + let mut component = LeaderboardComponent::HasComponent::get_component_mut(ref self); + Store::set_count(ref component, context_id, count); + } + fn get_entry_at(self: @T, context_id: u64, position: u32) -> felt252 { + Store::get_entry_at( + LeaderboardComponent::HasComponent::get_component(self), context_id, position, + ) + } + fn set_entry_at(ref self: T, context_id: u64, position: u32, token_id: felt252) { + let mut component = LeaderboardComponent::HasComponent::get_component_mut(ref self); + Store::set_entry_at(ref component, context_id, position, token_id); + } + fn get_score_at(self: @T, context_id: u64, position: u32) -> u64 { + Store::get_score_at( + LeaderboardComponent::HasComponent::get_component(self), context_id, position, + ) + } + fn set_score_at(ref self: T, context_id: u64, position: u32, score: u64) { + let mut component = LeaderboardComponent::HasComponent::get_component_mut(ref self); + Store::set_score_at(ref component, context_id, position, score); + } + fn get_token_position(self: @T, context_id: u64, token_id: felt252) -> u32 { + Store::get_token_position( + LeaderboardComponent::HasComponent::get_component(self), context_id, token_id, + ) + } + fn set_token_position(ref self: T, context_id: u64, token_id: felt252, position: u32) { + let mut component = LeaderboardComponent::HasComponent::get_component_mut(ref self); + Store::set_token_position(ref component, context_id, token_id, position); + } +} diff --git a/packages/metagame/src/leaderboard/tests.cairo b/packages/metagame/src/leaderboard/tests.cairo index ef17274f..a4bbc47d 100644 --- a/packages/metagame/src/leaderboard/tests.cairo +++ b/packages/metagame/src/leaderboard/tests.cairo @@ -1,6 +1,8 @@ // Re-export mock contracts for testing - snforge needs contract source in the test package mod mocks; mod test_gas_benchmark; + +mod test_host_storage; mod test_leaderboard; mod test_leaderboard_component; mod test_leaderboard_pure; diff --git a/packages/metagame/src/leaderboard/tests/test_host_storage.cairo b/packages/metagame/src/leaderboard/tests/test_host_storage.cairo new file mode 100644 index 00000000..77d6da1d --- /dev/null +++ b/packages/metagame/src/leaderboard/tests/test_host_storage.cairo @@ -0,0 +1,237 @@ +use game_components_interfaces::leaderboard::{ + ILeaderboardDispatcher, ILeaderboardDispatcherTrait, LeaderboardResult, +}; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; + +#[starknet::interface] +trait IHostLeaderboard { + fn configure(ref self: T, id: u64, max: u32, ascending: bool); + fn submit(ref self: T, id: u64, token: felt252, score: u64, position: u32) -> LeaderboardResult; + fn hook_count(self: @T) -> u32; +} + +#[starknet::contract] +mod HostLeaderboard { + use game_components_interfaces::leaderboard::{LeaderboardResult, LeaderboardStoreConfig}; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use crate::leaderboard::api::{Configuration, LeaderboardInternalImpl, LeaderboardInternalTrait}; + use crate::leaderboard::hooks::LeaderboardHooksTrait; + use crate::leaderboard::store::Store; + #[storage] + struct Storage { + packed_config: Map, + count: Map, + tokens: Map<(u64, u32), felt252>, + points: Map<(u64, u32), u64>, + positions: Map<(u64, felt252), u32>, + hooks: u32, + } + #[abi(embed_v0)] + impl Views = crate::leaderboard::api::LeaderboardImpl; + impl Config of Configuration { + fn leaderboard_config(self: @ContractState, context_id: u64) -> LeaderboardStoreConfig { + let packed = self.packed_config.read(context_id); + LeaderboardStoreConfig { + max_entries: (packed & 0xffffffff).try_into().unwrap(), + ascending: packed / 0x100000000 == 1, + game_address: 0.try_into().unwrap(), + } + } + } + impl Hooks of LeaderboardHooksTrait { + fn on_score_submitted( + ref self: ContractState, context_id: u64, token_id: felt252, score: u64, position: u32, + ) { + self.hooks.write(self.hooks.read() + 1); + } + fn on_configured( + ref self: ContractState, + context_id: u64, + max_entries: u32, + ascending: bool, + game_address: starknet::ContractAddress, + ) {} + fn on_cleared(ref self: ContractState, context_id: u64) {} + fn on_ownership_transferred( + ref self: ContractState, + previous_owner: starknet::ContractAddress, + new_owner: starknet::ContractAddress, + ) {} + } + impl HostStore of Store { + fn get_leaderboard(self: @ContractState, context_id: u64) -> Span { + let mut out = array![]; + for i in 0..self.count.read(context_id) { + out.append(self.tokens.read((context_id, i))); + } + out.span() + } + fn get_count(self: @ContractState, context_id: u64) -> u32 { + self.count.read(context_id) + } + fn set_count(ref self: ContractState, context_id: u64, count: u32) { + self.count.write(context_id, count); + } + fn get_entry_at(self: @ContractState, context_id: u64, position: u32) -> felt252 { + self.tokens.read((context_id, position)) + } + fn set_entry_at( + ref self: ContractState, context_id: u64, position: u32, token_id: felt252, + ) { + self.tokens.write((context_id, position), token_id); + } + fn get_score_at(self: @ContractState, context_id: u64, position: u32) -> u64 { + self.points.read((context_id, position)) + } + fn set_score_at(ref self: ContractState, context_id: u64, position: u32, score: u64) { + self.points.write((context_id, position), score); + } + fn get_token_position(self: @ContractState, context_id: u64, token_id: felt252) -> u32 { + self.positions.read((context_id, token_id)) + } + fn set_token_position( + ref self: ContractState, context_id: u64, token_id: felt252, position: u32, + ) { + self.positions.write((context_id, token_id), position); + } + } + #[abi(embed_v0)] + impl TestApi of super::IHostLeaderboard { + fn configure(ref self: ContractState, id: u64, max: u32, ascending: bool) { + self.packed_config.write(id, max.into() + if ascending { + 0x100000000 + } else { + 0 + }); + } + fn submit( + ref self: ContractState, id: u64, token: felt252, score: u64, position: u32, + ) -> LeaderboardResult { + LeaderboardInternalTrait::submit_score(ref self, id, token, score, position) + } + fn hook_count(self: @ContractState) -> u32 { + self.hooks.read() + } + } +} +fn deploy() -> (IHostLeaderboardDispatcher, ILeaderboardDispatcher) { + let cls = declare("HostLeaderboard").unwrap().contract_class(); + let (address, _) = cls.deploy(@array![]).unwrap(); + ( + IHostLeaderboardDispatcher { contract_address: address }, + ILeaderboardDispatcher { contract_address: address }, + ) +} +#[test] +fn leaderboard_host_storage_uses_packed_config_and_shared_views() { + let (host, board) = deploy(); + host.configure(1, 2, false); + host.configure(2, 2, true); + assert!(board.get_config(1).max_entries == 2 && !board.get_config(1).ascending); + assert!(board.get_leaderboard_entries(1).is_empty()); + assert!(host.submit(1, 11, 50, 1) == LeaderboardResult::Success); + assert!(host.submit(1, 12, 40, 2) == LeaderboardResult::Success); + assert!(board.is_full(1) && board.get_leaderboard_length(1) == 2); + assert!(!board.qualifies(1, 30) && board.qualifies(1, 60)); + assert!(board.find_position(1, 60, 13) == Option::Some(1)); + assert!(board.get_leaderboard_entry(1, 2).id == 12); + assert!(board.get_top_leaderboard_entries(1, 100).len() == 2); + assert!(board.get_top_leaderboard_entries(1, 0).is_empty()); + assert!(host.submit(1, 13, 60, 1) == LeaderboardResult::Success); + assert!(board.get_position(1, 11).is_none() && board.get_position(1, 13) == Option::Some(1)); + assert!(host.submit(2, 21, 10, 1) == LeaderboardResult::Success); + assert!(host.submit(2, 22, 20, 2) == LeaderboardResult::Success); + assert!(board.find_position(2, 5, 23) == Option::Some(1)); + assert!( + board.get_leaderboard_entries(1).len() == 2 && board.get_leaderboard_entries(2).len() == 2, + ); + assert!(host.hook_count() == 5); + assert!(host.submit(1, 13, 60, 1) == LeaderboardResult::DuplicateEntry); + assert!(host.submit(1, 14, 1, 3) == LeaderboardResult::LeaderboardFull); + assert!(host.submit(1, 14, 60, 0) == LeaderboardResult::InvalidPosition); + assert!(host.hook_count() == 5, "failed submissions must not trigger hooks"); +} +#[test] +#[should_panic(expected: "Leaderboard: position must be 1-indexed")] +fn leaderboard_host_storage_rejects_zero_position_view() { + let (_, board) = deploy(); + board.get_leaderboard_entry(1, 0); +} +#[test] +#[should_panic(expected: "Leaderboard: position 1 out of range")] +fn leaderboard_host_storage_rejects_missing_position_view() { + let (_, board) = deploy(); + board.get_leaderboard_entry(1, 1); +} + +#[starknet::contract] +mod ComponentBackedLeaderboard { + use game_components_interfaces::leaderboard::{ + ILeaderboard, LeaderboardResult, LeaderboardStoreConfig, + }; + use openzeppelin_introspection::src5::SRC5Component; + use crate::leaderboard::api::{ + Configuration, LeaderboardInternalImpl, LeaderboardInternalTrait as HostInternalTrait, + }; + use crate::leaderboard::leaderboard_component::LeaderboardComponent::LeaderboardInternalTrait; + use crate::leaderboard::leaderboard_component::{ + LeaderboardComponent, LeaderboardHooksEmptyImpl, + }; + use crate::leaderboard::store::Store; + component!(path: LeaderboardComponent, storage: leaderboard, event: LeaderboardEvent); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + impl Hooks = LeaderboardHooksEmptyImpl; + impl StorageAdapter = crate::leaderboard::storage_adapter::ComponentStore; + #[abi(embed_v0)] + impl Views = crate::leaderboard::api::LeaderboardImpl; + impl Config of Configuration { + fn leaderboard_config(self: @ContractState, context_id: u64) -> LeaderboardStoreConfig { + ILeaderboard::get_config(self.leaderboard, context_id) + } + } + #[storage] + struct Storage { + #[substorage(v0)] + leaderboard: LeaderboardComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + } + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + LeaderboardEvent: LeaderboardComponent::Event, + SRC5Event: SRC5Component::Event, + } + #[abi(embed_v0)] + impl TestApi of super::IHostLeaderboard { + fn configure(ref self: ContractState, id: u64, max: u32, ascending: bool) { + self.leaderboard._configure(id, max, ascending, 0.try_into().unwrap()); + } + fn submit( + ref self: ContractState, id: u64, token: felt252, score: u64, position: u32, + ) -> LeaderboardResult { + HostInternalTrait::submit_score(ref self, id, token, score, position) + } + fn hook_count(self: @ContractState) -> u32 { + Store::get_leaderboard(self, 1).len().try_into().unwrap() + } + } +} +#[test] +fn leaderboard_host_storage_component_adapter_preserves_component_maps() { + let cls = declare("ComponentBackedLeaderboard").unwrap().contract_class(); + let (address, _) = cls.deploy(@array![]).unwrap(); + let host = IHostLeaderboardDispatcher { contract_address: address }; + let board = ILeaderboardDispatcher { contract_address: address }; + host.configure(1, 2, false); + assert!(host.submit(1, 11, 50, 1) == LeaderboardResult::Success); + assert!(host.submit(1, 12, 40, 2) == LeaderboardResult::Success); + assert!(board.get_leaderboard_entry(1, 1).id == 11); + assert!(board.get_leaderboard_entries(1).len() == 2); + assert!(board.get_position(1, 12) == Option::Some(2)); + assert!(board.qualifies(1, 60) && board.is_full(1)); + assert!(host.hook_count() == 2); +} diff --git a/packages/metagame/src/prize.cairo b/packages/metagame/src/prize.cairo index e2e72ff8..aa388f6d 100644 --- a/packages/metagame/src/prize.cairo +++ b/packages/metagame/src/prize.cairo @@ -1,6 +1,8 @@ +pub mod api; pub mod prize; pub mod prize_component; pub mod prize_store; +pub mod storage_adapter; pub mod store; pub mod structs; diff --git a/packages/metagame/src/prize/api.cairo b/packages/metagame/src/prize/api.cairo new file mode 100644 index 00000000..0f47f580 --- /dev/null +++ b/packages/metagame/src/prize/api.cairo @@ -0,0 +1,375 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Storage-independent APIs. The host supplies Store and enforces access control, +//! lifecycle eligibility and reentrancy protection before internal mutations. +use core::num::traits::Zero; +use game_components_interfaces::prize::IPrize; +use metagame_extensions_interfaces::extension::ExtensionConfig; +use metagame_extensions_interfaces::prize_extension::{ + IPRIZE_EXTENSION_ID, IPrizeExtensionDispatcher, IPrizeExtensionDispatcherTrait, +}; +use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; +use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; +use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; +use starknet::{ContractAddress, get_caller_address, get_contract_address}; +use crate::prize::prize::prize::hash_prize_type; +use crate::prize::prize_store::{PrizeStoreImpl, PrizeStoreTrait}; +use crate::prize::store::Store; +use crate::prize::structs::{ + ExtensionPrizePayload, Prize, PrizeRecord, PrizeType, TokenPrizePayload, TokenTypeData, +}; + +/// Resolve a `prize_id` to its full `Prize` sum-type view. +/// +/// Branching: the `Prize_extension_prize_context` reverse index is +/// populated only for extension prizes. A non-zero read identifies +/// the prize as an extension, lets us reconstruct the +/// `(context_id, prize_id)` key, and we dispatch to the +/// extension's `get_config` view to fetch the original config +/// blob. Built-in prizes fall through to the +/// `PrizeStoreTrait::get_token_prize` store-bridge path. +fn resolve_prize, +Drop>( + self: @TContractState, prize_id: u64, +) -> PrizeRecord { + let context_id = Store::get_extension_prize_context(self, prize_id); + if context_id == 0 { + return PrizeStoreTrait::get_token_record(self, prize_id); + } + let extension_address = Store::get_extension_address(self, context_id, prize_id); + let sponsor_address = Store::get_extension_prize_sponsor(self, prize_id); + let context_owner = get_contract_address(); + let dispatcher = IPrizeExtensionDispatcher { contract_address: extension_address }; + let extension_config = dispatcher.get_config(context_owner, context_id, prize_id); + PrizeRecord { + id: prize_id, + context_id, + sponsor_address, + prize: Prize::Extension( + ExtensionPrizePayload { address: extension_address, config: extension_config }, + ), + } +} + +#[starknet::embeddable] +pub impl PrizeImpl< + TContractState, +Store, +Drop, +> of IPrize { + fn get_prize(self: @TContractState, prize_id: u64) -> PrizeRecord { + resolve_prize(self, prize_id) + } + + fn get_total_prizes(self: @TContractState) -> u64 { + PrizeStoreTrait::get_total_prizes(self) + } + + fn is_prize_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType) -> bool { + PrizeStoreTrait::is_prize_claimed(self, context_id, prize_type) + } +} + +#[generate_trait] +pub impl PrizeInternalImpl< + TContractState, +Store, +Drop, +> of PrizeInternalTrait { + /// Get a prize by its ID. + /// + /// For built-in (`Prize::Config`) prizes this reads the + /// `StoredPrize` slot and assembles a `PrizeData { kind: Config, ... }`. + /// + /// For extension (`Prize::Extension`) prizes this: + /// 1. detects the kind via the `prize_id -> context_id` + /// reverse index populated by `_set_extension`, + /// 2. reads the extension address from + /// `Prize_extension_address[(context_id, prize_id)]`, + /// 3. dispatches `IPrizeExtension.get_config(...)` to fetch + /// the original config blob the sponsor passed at + /// `add_prize` time, + /// 4. assembles a `PrizeData { kind: Extension, ... }`. + /// + /// Note: extension prizes incur a cross-contract call on every + /// read. Callers wanting bulk reads should batch / cache + /// accordingly. + fn _get_prize(self: @TContractState, prize_id: u64) -> PrizeRecord { + resolve_prize(self, prize_id) + } + + /// Get custom shares for a prize (used for Custom distribution) + /// Uses packed storage: reads 1 slot per 15 shares instead of 1 slot per share + fn _get_custom_shares(self: @TContractState, prize_id: u64) -> Array { + PrizeStoreTrait::get_custom_shares(self, prize_id) + } + + /// One custom share by 1-indexed position — a single storage read. + /// + /// `_get_prize` deliberately returns Custom with an empty span, so a + /// claim settling one position reads its share through here instead of + /// paying to rebuild the whole curve. + fn _get_custom_share_at(self: @TContractState, prize_id: u64, position: u32) -> u16 { + PrizeStoreTrait::get_custom_share_at(self, prize_id, position) + } + + /// Store a token-prize record (converts to StoredPrize for storage). + /// Extension prizes are not persisted via this path. + fn set_token_record( + ref self: TContractState, + prize_id: u64, + context_id: u64, + sponsor_address: ContractAddress, + payload: TokenPrizePayload, + ) { + PrizeStoreTrait::set_token_record(ref self, prize_id, context_id, sponsor_address, payload); + } + + /// Get total prizes count (internal) + fn _get_total_prizes(self: @TContractState) -> u64 { + PrizeStoreTrait::get_total_prizes(self) + } + + /// Increment total prizes and return the new prize ID + fn increment_prize_count(ref self: TContractState) -> u64 { + PrizeStoreTrait::increment_prize_count(ref self) + } + + /// Hash a prize type for use as storage key + fn hash_prize_type(self: @TContractState, prize_type: PrizeType) -> felt252 { + hash_prize_type(prize_type) + } + + /// Check if a prize has been claimed (internal) + fn _is_prize_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType) -> bool { + PrizeStoreTrait::is_prize_claimed(self, context_id, prize_type) + } + + /// Check if a prize has been claimed using pre-computed hash (gas optimization) + fn _is_prize_claimed_by_hash( + self: @TContractState, context_id: u64, prize_type_hash: felt252, + ) -> bool { + PrizeStoreTrait::is_prize_claimed_by_hash(self, context_id, prize_type_hash) + } + + /// Mark a prize as claimed + fn set_prize_claimed(ref self: TContractState, context_id: u64, prize_type: PrizeType) { + PrizeStoreTrait::set_prize_claimed(ref self, context_id, prize_type); + } + + /// Mark a prize as claimed using pre-computed hash (gas optimization) + fn _set_prize_claimed_by_hash( + ref self: TContractState, context_id: u64, prize_type_hash: felt252, + ) { + PrizeStoreTrait::set_prize_claimed_by_hash(ref self, context_id, prize_type_hash); + } + + /// Assert that a prize exists (has non-zero token address) + fn assert_prize_exists(self: @TContractState, prize_id: u64) { + PrizeStoreTrait::assert_prize_exists(self, prize_id); + } + + /// Assert that a prize has not been claimed + fn assert_prize_not_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType) { + PrizeStoreTrait::assert_prize_not_claimed(self, context_id, prize_type); + } + + /// Read the leaderboard position a built-in prize pays out to. + /// Zero when unset (distributed prizes, extension prizes, or + /// prizes added without a position). + fn get_payout_position(self: @TContractState, prize_id: u64) -> u32 { + Store::get_payout_position(self, prize_id) + } + + /// Record the leaderboard position a built-in prize pays out to. + /// Hosts call this at add_prize time for non-distributed token + /// prizes; the component then owns the per-prize position storage + /// so any other consumer of PrizeComponent shares the same + /// position-aware claim flow. + fn set_payout_position(ref self: TContractState, prize_id: u64, position: u32) { + Store::set_payout_position(ref self, prize_id, position); + } + + /// Assert that a prize has not been claimed using pre-computed hash (gas optimization) + fn _assert_prize_not_claimed_by_hash( + self: @TContractState, context_id: u64, prize_type_hash: felt252, + ) { + PrizeStoreTrait::assert_prize_not_claimed_by_hash(self, context_id, prize_type_hash); + } + + /// Add a prize or set extension for a context. + /// Returns the prize_id in both cases. + /// - `Prize::Token(payload)`: deposits tokens, stores prize + /// data. Host assigns id/context_id/sponsor_address. + /// - `Prize::Extension(payload)`: increments prize count, + /// registers the extension address keyed by + /// `(context_id, prize_id)`, captures sponsor, and + /// dispatches `IPrizeExtension.add_prize(...)`. + fn add_prize(ref self: TContractState, context_id: u64, prize: Prize) -> u64 { + match prize { + Prize::Token(payload) => self._add_token_prize(context_id, payload), + Prize::Extension(payload) => { + let ext = ExtensionConfig { address: payload.address, config: payload.config }; + assert!(!ext.address.is_zero(), "Prize: Extension address cannot be zero"); + let src5 = ISRC5Dispatcher { contract_address: ext.address }; + let display_address: felt252 = ext.address.into(); + assert!( + src5.supports_interface(IPRIZE_EXTENSION_ID), + "Prize: Extension {} does not support IPrizeExtension", + display_address, + ); + let prize_id = PrizeStoreTrait::increment_prize_count(ref self); + self._set_extension(context_id, prize_id, ext); + prize_id + }, + } + } + + /// Internal: deposit tokens, store prize data, return prize_id. + /// Host fills in the id (assigned), context_id (caller-supplied), + /// and sponsor_address (`get_caller_address()`) around the + /// supplied `payload`. + fn _add_token_prize( + ref self: TContractState, context_id: u64, payload: TokenPrizePayload, + ) -> u64 { + let token_address = payload.token_address; + let token_type = payload.token_type; + + // Deposit the prize tokens + match @token_type { + TokenTypeData::erc20(erc20_data) => { + let amount = *erc20_data.amount; + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + assert!(amount > 0, "Prize: ERC20 prize token amount must be greater than 0"); + assert!( + token_dispatcher + .transfer_from(get_caller_address(), get_contract_address(), amount.into()), + "Prize: ERC20 transfer_from failed", + ); + }, + TokenTypeData::erc721(erc721_data) => { + let token_id = *erc721_data.id; + let token_dispatcher = IERC721Dispatcher { contract_address: token_address }; + token_dispatcher + .transfer_from(get_caller_address(), get_contract_address(), token_id.into()); + }, + } + + // Get next prize ID + let id = PrizeStoreTrait::increment_prize_count(ref self); + + // Store custom shares if this is a Custom distribution (using packed storage) + if let TokenTypeData::erc20(erc20_data) = @token_type { + if let Option::Some(dist) = erc20_data.distribution { + if let game_components_utilities::distribution::structs::Distribution::Custom(shares) = + dist { + PrizeStoreTrait::store_custom_shares(ref self, id, *shares); + } + } + } + + // Persist the built-in token prize. Host fills id, + // context_id, sponsor_address around the supplied payload. + let sponsor = get_caller_address(); + let payload = TokenPrizePayload { token_address, token_type }; + PrizeStoreTrait::set_token_record(ref self, id, context_id, sponsor, payload); + + id + } + + /// Internal: persist the extension address + the reverse + /// `prize_id -> context_id` index, then forward the config to + /// the extension. The config blob is NOT persisted here — it + /// lives on the extension contract, and reads (`get_prize`) + /// fetch it back via `IPrizeExtension.get_config`. + fn _set_extension( + ref self: TContractState, context_id: u64, prize_id: u64, ext: ExtensionConfig, + ) { + Store::set_extension_address(ref self, context_id, prize_id, ext.address); + // Reverse index for the prize_id-only `get_prize` lookup. + Store::set_extension_prize_context(ref self, prize_id, context_id); + // Capture sponsor (the caller of the host's `add_prize`). + Store::set_extension_prize_sponsor(ref self, prize_id, get_caller_address()); + + let dispatcher = IPrizeExtensionDispatcher { contract_address: ext.address }; + dispatcher.add_prize(context_id, prize_id, ext.config); + } + + /// Forward a payout call to the prize extension configured for + /// `(context_id, prize_id)`. Reverts if `prize_id` was added via + /// the built-in `Prize::Token` path (no extension address stored). + /// + /// The host is a pure dispatcher: it passes the supplied + /// `(token_id, payout_params)` straight through. The extension + /// resolves recipient, eligibility, and dedupe entirely from its + /// own state. `token_id = Some(id)` typically signals a claim + /// (extension derives recipient via `owner_of(id)`); `None` + /// signals a non-claim flow (sponsor refund, dao distribution, + /// raffle) and the extension extracts whatever it needs from + /// `payout_params`. + /// + /// Hosts are responsible for any cross-cutting concerns + /// (finalization checks, reentrancy guards) before invoking this. + fn payout_prize_extension( + ref self: TContractState, + context_id: u64, + prize_id: u64, + token_id: Option, + payout_params: Span, + ) { + let extension_address = Store::get_extension_address(@self, context_id, prize_id); + assert!(!extension_address.is_zero(), "Prize: No extension configured for prize"); + let dispatcher = IPrizeExtensionDispatcher { contract_address: extension_address }; + dispatcher.payout_prize(context_id, prize_id, token_id, payout_params); + } + + /// Payout full ERC20 amount to a recipient + fn payout_erc20( + ref self: TContractState, + token_address: ContractAddress, + amount: u128, + recipient: ContractAddress, + ) { + let erc20 = IERC20Dispatcher { contract_address: token_address }; + assert!(erc20.transfer(recipient, amount.into()), "Prize: ERC20 transfer failed"); + } + + /// Payout ERC721 to a recipient + fn payout_erc721( + ref self: TContractState, + token_address: ContractAddress, + token_id: u128, + recipient: ContractAddress, + ) { + let erc721 = IERC721Dispatcher { contract_address: token_address }; + erc721.transfer_from(get_contract_address(), recipient, token_id.into()); + } + + /// Refund ERC20 prize to the original sponsor + fn refund_prize_erc20(ref self: TContractState, prize_id: u64, amount: u128) { + let record = PrizeStoreTrait::get_token_record(@self, prize_id); + let token_address = match record.prize { + Prize::Token(payload) => payload.token_address, + Prize::Extension(_) => panic!("Prize: extension prize cannot be refunded as ERC20"), + }; + let erc20 = IERC20Dispatcher { contract_address: token_address }; + assert!( + erc20.transfer(record.sponsor_address, amount.into()), + "Prize: ERC20 refund transfer failed", + ); + } + + /// Refund ERC721 prize to the original sponsor + fn refund_prize_erc721(ref self: TContractState, prize_id: u64, token_id: u128) { + let record = PrizeStoreTrait::get_token_record(@self, prize_id); + let token_address = match record.prize { + Prize::Token(payload) => payload.token_address, + Prize::Extension(_) => panic!("Prize: extension prize cannot be refunded as ERC721"), + }; + let erc721 = IERC721Dispatcher { contract_address: token_address }; + erc721.transfer_from(get_contract_address(), record.sponsor_address, token_id.into()); + } + + // --- Extension helpers --- + + /// Get extension address for a context and prize (zero = built-in). + fn get_extension_address( + self: @TContractState, context_id: u64, prize_id: u64, + ) -> ContractAddress { + Store::get_extension_address(self, context_id, prize_id) + } +} diff --git a/packages/metagame/src/prize/prize_component.cairo b/packages/metagame/src/prize/prize_component.cairo index 4d7fbb5c..dc4a6e95 100644 --- a/packages/metagame/src/prize/prize_component.cairo +++ b/packages/metagame/src/prize/prize_component.cairo @@ -11,27 +11,17 @@ #[starknet::component] pub mod PrizeComponent { - use core::num::traits::Zero; use game_components_interfaces::prize::{IPRIZE_ID, IPrize}; use metagame_extensions_interfaces::extension::ExtensionConfig; - use metagame_extensions_interfaces::prize_extension::{ - IPRIZE_EXTENSION_ID, IPrizeExtensionDispatcher, IPrizeExtensionDispatcherTrait, - }; - use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait}; - use openzeppelin_interfaces::erc721::{IERC721Dispatcher, IERC721DispatcherTrait}; - use openzeppelin_interfaces::introspection::{ISRC5Dispatcher, ISRC5DispatcherTrait}; use openzeppelin_introspection::src5::SRC5Component; use openzeppelin_introspection::src5::SRC5Component::InternalTrait as SRC5InternalTrait; + use starknet::ContractAddress; use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use starknet::{ContractAddress, get_caller_address, get_contract_address}; - use crate::prize::prize::prize::hash_prize_type; - use crate::prize::prize_store::{PrizeStoreImpl, PrizeStoreTrait}; use crate::prize::store::Store; use crate::prize::structs::{ - CustomShares, ExtensionPrizePayload, Prize, PrizeRecord, PrizeType, StoredPrize, - TokenPrizePayload, TokenTypeData, + CustomShares, Prize, PrizeRecord, PrizeType, StoredPrize, TokenPrizePayload, }; #[storage] @@ -181,53 +171,23 @@ pub mod PrizeComponent { } } - /// Resolve a `prize_id` to its full `Prize` sum-type view. - /// - /// Branching: the `Prize_extension_prize_context` reverse index is - /// populated only for extension prizes. A non-zero read identifies - /// the prize as an extension, lets us reconstruct the - /// `(context_id, prize_id)` key, and we dispatch to the - /// extension's `get_config` view to fetch the original config - /// blob. Built-in prizes fall through to the - /// `PrizeStoreTrait::get_token_prize` store-bridge path. - fn resolve_prize>( - self: @ComponentState, prize_id: u64, - ) -> PrizeRecord { - let context_id = Store::get_extension_prize_context(self, prize_id); - if context_id == 0 { - return PrizeStoreTrait::get_token_record(self, prize_id); - } - let extension_address = Store::get_extension_address(self, context_id, prize_id); - let sponsor_address = Store::get_extension_prize_sponsor(self, prize_id); - let context_owner = get_contract_address(); - let dispatcher = IPrizeExtensionDispatcher { contract_address: extension_address }; - let extension_config = dispatcher.get_config(context_owner, context_id, prize_id); - PrizeRecord { - id: prize_id, - context_id, - sponsor_address, - prize: Prize::Extension( - ExtensionPrizePayload { address: extension_address, config: extension_config }, - ), - } - } #[embeddable_as(PrizeImpl)] impl PrizeComponentImpl< TContractState, +HasComponent, > of IPrize> { fn get_prize(self: @ComponentState, prize_id: u64) -> PrizeRecord { - resolve_prize(self, prize_id) + crate::prize::api::PrizeImpl::get_prize(self, prize_id) } fn get_total_prizes(self: @ComponentState) -> u64 { - PrizeStoreTrait::get_total_prizes(self) + crate::prize::api::PrizeImpl::get_total_prizes(self) } fn is_prize_claimed( self: @ComponentState, context_id: u64, prize_type: PrizeType, ) -> bool { - PrizeStoreTrait::is_prize_claimed(self, context_id, prize_type) + crate::prize::api::PrizeImpl::is_prize_claimed(self, context_id, prize_type) } } @@ -254,13 +214,13 @@ pub mod PrizeComponent { /// read. Callers wanting bulk reads should batch / cache /// accordingly. fn _get_prize(self: @ComponentState, prize_id: u64) -> PrizeRecord { - resolve_prize(self, prize_id) + crate::prize::api::PrizeInternalImpl::_get_prize(self, prize_id) } /// Get custom shares for a prize (used for Custom distribution) /// Uses packed storage: reads 1 slot per 15 shares instead of 1 slot per share fn _get_custom_shares(self: @ComponentState, prize_id: u64) -> Array { - PrizeStoreTrait::get_custom_shares(self, prize_id) + crate::prize::api::PrizeInternalImpl::_get_custom_shares(self, prize_id) } /// One custom share by 1-indexed position — a single storage read. @@ -271,7 +231,7 @@ pub mod PrizeComponent { fn _get_custom_share_at( self: @ComponentState, prize_id: u64, position: u32, ) -> u16 { - PrizeStoreTrait::get_custom_share_at(self, prize_id, position) + crate::prize::api::PrizeInternalImpl::_get_custom_share_at(self, prize_id, position) } /// Store a token-prize record (converts to StoredPrize for storage). @@ -283,73 +243,81 @@ pub mod PrizeComponent { sponsor_address: ContractAddress, payload: TokenPrizePayload, ) { - PrizeStoreTrait::set_token_record( + crate::prize::api::PrizeInternalImpl::set_token_record( ref self, prize_id, context_id, sponsor_address, payload, - ); + ) } /// Get total prizes count (internal) fn _get_total_prizes(self: @ComponentState) -> u64 { - PrizeStoreTrait::get_total_prizes(self) + crate::prize::api::PrizeInternalImpl::_get_total_prizes(self) } /// Increment total prizes and return the new prize ID fn increment_prize_count(ref self: ComponentState) -> u64 { - PrizeStoreTrait::increment_prize_count(ref self) + crate::prize::api::PrizeInternalImpl::increment_prize_count(ref self) } /// Hash a prize type for use as storage key fn hash_prize_type( self: @ComponentState, prize_type: PrizeType, ) -> felt252 { - hash_prize_type(prize_type) + crate::prize::api::PrizeInternalImpl::hash_prize_type(self, prize_type) } /// Check if a prize has been claimed (internal) fn _is_prize_claimed( self: @ComponentState, context_id: u64, prize_type: PrizeType, ) -> bool { - PrizeStoreTrait::is_prize_claimed(self, context_id, prize_type) + crate::prize::api::PrizeInternalImpl::_is_prize_claimed(self, context_id, prize_type) } /// Check if a prize has been claimed using pre-computed hash (gas optimization) fn _is_prize_claimed_by_hash( self: @ComponentState, context_id: u64, prize_type_hash: felt252, ) -> bool { - PrizeStoreTrait::is_prize_claimed_by_hash(self, context_id, prize_type_hash) + crate::prize::api::PrizeInternalImpl::_is_prize_claimed_by_hash( + self, context_id, prize_type_hash, + ) } /// Mark a prize as claimed fn set_prize_claimed( ref self: ComponentState, context_id: u64, prize_type: PrizeType, ) { - PrizeStoreTrait::set_prize_claimed(ref self, context_id, prize_type); + crate::prize::api::PrizeInternalImpl::set_prize_claimed( + ref self, context_id, prize_type, + ) } /// Mark a prize as claimed using pre-computed hash (gas optimization) fn _set_prize_claimed_by_hash( ref self: ComponentState, context_id: u64, prize_type_hash: felt252, ) { - PrizeStoreTrait::set_prize_claimed_by_hash(ref self, context_id, prize_type_hash); + crate::prize::api::PrizeInternalImpl::_set_prize_claimed_by_hash( + ref self, context_id, prize_type_hash, + ) } /// Assert that a prize exists (has non-zero token address) fn assert_prize_exists(self: @ComponentState, prize_id: u64) { - PrizeStoreTrait::assert_prize_exists(self, prize_id); + crate::prize::api::PrizeInternalImpl::assert_prize_exists(self, prize_id) } /// Assert that a prize has not been claimed fn assert_prize_not_claimed( self: @ComponentState, context_id: u64, prize_type: PrizeType, ) { - PrizeStoreTrait::assert_prize_not_claimed(self, context_id, prize_type); + crate::prize::api::PrizeInternalImpl::assert_prize_not_claimed( + self, context_id, prize_type, + ) } /// Read the leaderboard position a built-in prize pays out to. /// Zero when unset (distributed prizes, extension prizes, or /// prizes added without a position). fn get_payout_position(self: @ComponentState, prize_id: u64) -> u32 { - Store::get_payout_position(self, prize_id) + crate::prize::api::PrizeInternalImpl::get_payout_position(self, prize_id) } /// Record the leaderboard position a built-in prize pays out to. @@ -360,14 +328,16 @@ pub mod PrizeComponent { fn set_payout_position( ref self: ComponentState, prize_id: u64, position: u32, ) { - Store::set_payout_position(ref self, prize_id, position); + crate::prize::api::PrizeInternalImpl::set_payout_position(ref self, prize_id, position) } /// Assert that a prize has not been claimed using pre-computed hash (gas optimization) fn _assert_prize_not_claimed_by_hash( self: @ComponentState, context_id: u64, prize_type_hash: felt252, ) { - PrizeStoreTrait::assert_prize_not_claimed_by_hash(self, context_id, prize_type_hash); + crate::prize::api::PrizeInternalImpl::_assert_prize_not_claimed_by_hash( + self, context_id, prize_type_hash, + ) } /// Add a prize or set extension for a context. @@ -381,23 +351,7 @@ pub mod PrizeComponent { fn add_prize( ref self: ComponentState, context_id: u64, prize: Prize, ) -> u64 { - match prize { - Prize::Token(payload) => self._add_token_prize(context_id, payload), - Prize::Extension(payload) => { - let ext = ExtensionConfig { address: payload.address, config: payload.config }; - assert!(!ext.address.is_zero(), "Prize: Extension address cannot be zero"); - let src5 = ISRC5Dispatcher { contract_address: ext.address }; - let display_address: felt252 = ext.address.into(); - assert!( - src5.supports_interface(IPRIZE_EXTENSION_ID), - "Prize: Extension {} does not support IPrizeExtension", - display_address, - ); - let prize_id = PrizeStoreTrait::increment_prize_count(ref self); - self._set_extension(context_id, prize_id, ext); - prize_id - }, - } + crate::prize::api::PrizeInternalImpl::add_prize(ref self, context_id, prize) } /// Internal: deposit tokens, store prize data, return prize_id. @@ -407,53 +361,7 @@ pub mod PrizeComponent { fn _add_token_prize( ref self: ComponentState, context_id: u64, payload: TokenPrizePayload, ) -> u64 { - let token_address = payload.token_address; - let token_type = payload.token_type; - - // Deposit the prize tokens - match @token_type { - TokenTypeData::erc20(erc20_data) => { - let amount = *erc20_data.amount; - let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; - assert!(amount > 0, "Prize: ERC20 prize token amount must be greater than 0"); - assert!( - token_dispatcher - .transfer_from( - get_caller_address(), get_contract_address(), amount.into(), - ), - "Prize: ERC20 transfer_from failed", - ); - }, - TokenTypeData::erc721(erc721_data) => { - let token_id = *erc721_data.id; - let token_dispatcher = IERC721Dispatcher { contract_address: token_address }; - token_dispatcher - .transfer_from( - get_caller_address(), get_contract_address(), token_id.into(), - ); - }, - } - - // Get next prize ID - let id = PrizeStoreTrait::increment_prize_count(ref self); - - // Store custom shares if this is a Custom distribution (using packed storage) - if let TokenTypeData::erc20(erc20_data) = @token_type { - if let Option::Some(dist) = erc20_data.distribution { - if let game_components_utilities::distribution::structs::Distribution::Custom(shares) = - dist { - PrizeStoreTrait::store_custom_shares(ref self, id, *shares); - } - } - } - - // Persist the built-in token prize. Host fills id, - // context_id, sponsor_address around the supplied payload. - let sponsor = get_caller_address(); - let payload = TokenPrizePayload { token_address, token_type }; - PrizeStoreTrait::set_token_record(ref self, id, context_id, sponsor, payload); - - id + crate::prize::api::PrizeInternalImpl::_add_token_prize(ref self, context_id, payload) } /// Internal: persist the extension address + the reverse @@ -467,14 +375,9 @@ pub mod PrizeComponent { prize_id: u64, ext: ExtensionConfig, ) { - Store::set_extension_address(ref self, context_id, prize_id, ext.address); - // Reverse index for the prize_id-only `get_prize` lookup. - Store::set_extension_prize_context(ref self, prize_id, context_id); - // Capture sponsor (the caller of the host's `add_prize`). - Store::set_extension_prize_sponsor(ref self, prize_id, get_caller_address()); - - let dispatcher = IPrizeExtensionDispatcher { contract_address: ext.address }; - dispatcher.add_prize(context_id, prize_id, ext.config); + crate::prize::api::PrizeInternalImpl::_set_extension( + ref self, context_id, prize_id, ext, + ) } /// Forward a payout call to the prize extension configured for @@ -499,10 +402,9 @@ pub mod PrizeComponent { token_id: Option, payout_params: Span, ) { - let extension_address = Store::get_extension_address(@self, context_id, prize_id); - assert!(!extension_address.is_zero(), "Prize: No extension configured for prize"); - let dispatcher = IPrizeExtensionDispatcher { contract_address: extension_address }; - dispatcher.payout_prize(context_id, prize_id, token_id, payout_params); + crate::prize::api::PrizeInternalImpl::payout_prize_extension( + ref self, context_id, prize_id, token_id, payout_params, + ) } /// Payout full ERC20 amount to a recipient @@ -512,8 +414,9 @@ pub mod PrizeComponent { amount: u128, recipient: ContractAddress, ) { - let erc20 = IERC20Dispatcher { contract_address: token_address }; - assert!(erc20.transfer(recipient, amount.into()), "Prize: ERC20 transfer failed"); + crate::prize::api::PrizeInternalImpl::payout_erc20( + ref self, token_address, amount, recipient, + ) } /// Payout ERC721 to a recipient @@ -523,39 +426,23 @@ pub mod PrizeComponent { token_id: u128, recipient: ContractAddress, ) { - let erc721 = IERC721Dispatcher { contract_address: token_address }; - erc721.transfer_from(get_contract_address(), recipient, token_id.into()); + crate::prize::api::PrizeInternalImpl::payout_erc721( + ref self, token_address, token_id, recipient, + ) } /// Refund ERC20 prize to the original sponsor fn refund_prize_erc20( ref self: ComponentState, prize_id: u64, amount: u128, ) { - let record = PrizeStoreTrait::get_token_record(@self, prize_id); - let token_address = match record.prize { - Prize::Token(payload) => payload.token_address, - Prize::Extension(_) => panic!("Prize: extension prize cannot be refunded as ERC20"), - }; - let erc20 = IERC20Dispatcher { contract_address: token_address }; - assert!( - erc20.transfer(record.sponsor_address, amount.into()), - "Prize: ERC20 refund transfer failed", - ); + crate::prize::api::PrizeInternalImpl::refund_prize_erc20(ref self, prize_id, amount) } /// Refund ERC721 prize to the original sponsor fn refund_prize_erc721( ref self: ComponentState, prize_id: u64, token_id: u128, ) { - let record = PrizeStoreTrait::get_token_record(@self, prize_id); - let token_address = match record.prize { - Prize::Token(payload) => payload.token_address, - Prize::Extension(_) => panic!( - "Prize: extension prize cannot be refunded as ERC721", - ), - }; - let erc721 = IERC721Dispatcher { contract_address: token_address }; - erc721.transfer_from(get_contract_address(), record.sponsor_address, token_id.into()); + crate::prize::api::PrizeInternalImpl::refund_prize_erc721(ref self, prize_id, token_id) } // --- Extension helpers --- @@ -564,7 +451,7 @@ pub mod PrizeComponent { fn get_extension_address( self: @ComponentState, context_id: u64, prize_id: u64, ) -> ContractAddress { - Store::get_extension_address(self, context_id, prize_id) + crate::prize::api::PrizeInternalImpl::get_extension_address(self, context_id, prize_id) } } diff --git a/packages/metagame/src/prize/storage_adapter.cairo b/packages/metagame/src/prize/storage_adapter.cairo new file mode 100644 index 00000000..69999d14 --- /dev/null +++ b/packages/metagame/src/prize/storage_adapter.cairo @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Optional adapter retaining the original component storage maps. +use starknet::ContractAddress; +use crate::prize::prize_component::PrizeComponent; +use crate::prize::store::Store; +use crate::prize::structs::{CustomShares, StoredPrize}; + +pub impl ComponentStore, +Drop> of Store { + fn get_prize(self: @T, prize_id: u64) -> StoredPrize { + Store::get_prize(PrizeComponent::HasComponent::get_component(self), prize_id) + } + fn set_prize(ref self: T, prize_id: u64, prize: StoredPrize) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_prize(ref component, prize_id, prize); + } + fn get_claim(self: @T, context_id: u64, hash: felt252) -> bool { + Store::get_claim(PrizeComponent::HasComponent::get_component(self), context_id, hash) + } + fn set_claim(ref self: T, context_id: u64, hash: felt252, claimed: bool) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_claim(ref component, context_id, hash, claimed); + } + fn get_total_prizes(self: @T) -> u64 { + Store::get_total_prizes(PrizeComponent::HasComponent::get_component(self)) + } + fn set_total_prizes(ref self: T, count: u64) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_total_prizes(ref component, count); + } + fn get_custom_shares_count(self: @T, prize_id: u64) -> u32 { + Store::get_custom_shares_count(PrizeComponent::HasComponent::get_component(self), prize_id) + } + fn set_custom_shares_count(ref self: T, prize_id: u64, count: u32) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_custom_shares_count(ref component, prize_id, count); + } + fn get_custom_shares_packed(self: @T, prize_id: u64, slot: u8) -> CustomShares { + Store::get_custom_shares_packed( + PrizeComponent::HasComponent::get_component(self), prize_id, slot, + ) + } + fn set_custom_shares_packed(ref self: T, prize_id: u64, slot: u8, shares: CustomShares) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_custom_shares_packed(ref component, prize_id, slot, shares); + } + fn get_extension_address(self: @T, context_id: u64, prize_id: u64) -> ContractAddress { + Store::get_extension_address( + PrizeComponent::HasComponent::get_component(self), context_id, prize_id, + ) + } + fn set_extension_address(ref self: T, context_id: u64, prize_id: u64, addr: ContractAddress) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_extension_address(ref component, context_id, prize_id, addr); + } + fn get_extension_prize_context(self: @T, prize_id: u64) -> u64 { + Store::get_extension_prize_context( + PrizeComponent::HasComponent::get_component(self), prize_id, + ) + } + fn set_extension_prize_context(ref self: T, prize_id: u64, context_id: u64) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_extension_prize_context(ref component, prize_id, context_id); + } + fn get_extension_prize_sponsor(self: @T, prize_id: u64) -> ContractAddress { + Store::get_extension_prize_sponsor( + PrizeComponent::HasComponent::get_component(self), prize_id, + ) + } + fn set_extension_prize_sponsor(ref self: T, prize_id: u64, sponsor: ContractAddress) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_extension_prize_sponsor(ref component, prize_id, sponsor); + } + fn get_payout_position(self: @T, prize_id: u64) -> u32 { + Store::get_payout_position(PrizeComponent::HasComponent::get_component(self), prize_id) + } + fn set_payout_position(ref self: T, prize_id: u64, position: u32) { + let mut component = PrizeComponent::HasComponent::get_component_mut(ref self); + Store::set_payout_position(ref component, prize_id, position); + } +} diff --git a/packages/metagame/src/prize/store.cairo b/packages/metagame/src/prize/store.cairo index cfb7da6a..9af28092 100644 --- a/packages/metagame/src/prize/store.cairo +++ b/packages/metagame/src/prize/store.cairo @@ -4,7 +4,7 @@ use starknet::ContractAddress; use crate::prize::structs::{CustomShares, StoredPrize}; /// Generic store trait for prize operations. -/// Maps 1:1 to storage fields without business logic. +/// Logical accessors without business logic; hosts may pack, derive or share fields. pub trait Store { fn get_prize(self: @T, prize_id: u64) -> StoredPrize; fn set_prize(ref self: T, prize_id: u64, prize: StoredPrize); diff --git a/packages/metagame/src/prize/tests.cairo b/packages/metagame/src/prize/tests.cairo index 63ed369c..a32605e9 100644 --- a/packages/metagame/src/prize/tests.cairo +++ b/packages/metagame/src/prize/tests.cairo @@ -1,5 +1,7 @@ +pub mod host_fixtures; pub mod mocks; mod test_hash_caching; +mod test_host_storage; mod test_models; mod test_packed_custom_shares; mod test_prize_component; diff --git a/packages/metagame/src/prize/tests/host_fixtures.cairo b/packages/metagame/src/prize/tests/host_fixtures.cairo new file mode 100644 index 00000000..4de1c7ae --- /dev/null +++ b/packages/metagame/src/prize/tests/host_fixtures.cairo @@ -0,0 +1,325 @@ +use metagame_extensions_interfaces::extension::ExtensionConfig; +use starknet::ContractAddress; +use crate::prize::structs::{Prize, PrizeRecord, PrizeType, TokenPrizePayload}; +#[starknet::interface] +pub trait IHostPrize { + fn _get_prize(self: @T, prize_id: u64) -> PrizeRecord; + fn _get_custom_shares(self: @T, prize_id: u64) -> Array; + fn _get_custom_share_at(self: @T, prize_id: u64, position: u32) -> u16; + fn set_token_record( + ref self: T, + prize_id: u64, + context_id: u64, + sponsor_address: ContractAddress, + payload: TokenPrizePayload, + ); + fn _get_total_prizes(self: @T) -> u64; + fn increment_prize_count(ref self: T) -> u64; + fn hash_prize_type(self: @T, prize_type: PrizeType) -> felt252; + fn _is_prize_claimed(self: @T, context_id: u64, prize_type: PrizeType) -> bool; + fn _is_prize_claimed_by_hash(self: @T, context_id: u64, prize_type_hash: felt252) -> bool; + fn set_prize_claimed(ref self: T, context_id: u64, prize_type: PrizeType); + fn _set_prize_claimed_by_hash(ref self: T, context_id: u64, prize_type_hash: felt252); + fn assert_prize_exists(self: @T, prize_id: u64); + fn assert_prize_not_claimed(self: @T, context_id: u64, prize_type: PrizeType); + fn get_payout_position(self: @T, prize_id: u64) -> u32; + fn set_payout_position(ref self: T, prize_id: u64, position: u32); + fn _assert_prize_not_claimed_by_hash(self: @T, context_id: u64, prize_type_hash: felt252); + fn add_prize(ref self: T, context_id: u64, prize: Prize) -> u64; + fn _add_token_prize(ref self: T, context_id: u64, payload: TokenPrizePayload) -> u64; + fn _set_extension(ref self: T, context_id: u64, prize_id: u64, ext: ExtensionConfig); + fn payout_prize_extension( + ref self: T, + context_id: u64, + prize_id: u64, + token_id: Option, + payout_params: Span, + ); + fn payout_erc20( + ref self: T, token_address: ContractAddress, amount: u128, recipient: ContractAddress, + ); + fn payout_erc721( + ref self: T, token_address: ContractAddress, token_id: u128, recipient: ContractAddress, + ); + fn refund_prize_erc20(ref self: T, prize_id: u64, amount: u128); + fn refund_prize_erc721(ref self: T, prize_id: u64, token_id: u128); + fn get_extension_address(self: @T, context_id: u64, prize_id: u64) -> ContractAddress; +} +#[starknet::embeddable] +pub impl HostPrizeApi< + TContractState, +crate::prize::store::Store, +Drop, +> of IHostPrize { + fn _get_prize(self: @TContractState, prize_id: u64) -> PrizeRecord { + crate::prize::api::PrizeInternalImpl::_get_prize(self, prize_id) + } + fn _get_custom_shares(self: @TContractState, prize_id: u64) -> Array { + crate::prize::api::PrizeInternalImpl::_get_custom_shares(self, prize_id) + } + fn _get_custom_share_at(self: @TContractState, prize_id: u64, position: u32) -> u16 { + crate::prize::api::PrizeInternalImpl::_get_custom_share_at(self, prize_id, position) + } + fn set_token_record( + ref self: TContractState, + prize_id: u64, + context_id: u64, + sponsor_address: ContractAddress, + payload: TokenPrizePayload, + ) { + crate::prize::api::PrizeInternalImpl::set_token_record( + ref self, prize_id, context_id, sponsor_address, payload, + ) + } + fn _get_total_prizes(self: @TContractState) -> u64 { + crate::prize::api::PrizeInternalImpl::_get_total_prizes(self) + } + fn increment_prize_count(ref self: TContractState) -> u64 { + crate::prize::api::PrizeInternalImpl::increment_prize_count(ref self) + } + fn hash_prize_type(self: @TContractState, prize_type: PrizeType) -> felt252 { + crate::prize::api::PrizeInternalImpl::hash_prize_type(self, prize_type) + } + fn _is_prize_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType) -> bool { + crate::prize::api::PrizeInternalImpl::_is_prize_claimed(self, context_id, prize_type) + } + fn _is_prize_claimed_by_hash( + self: @TContractState, context_id: u64, prize_type_hash: felt252, + ) -> bool { + crate::prize::api::PrizeInternalImpl::_is_prize_claimed_by_hash( + self, context_id, prize_type_hash, + ) + } + fn set_prize_claimed(ref self: TContractState, context_id: u64, prize_type: PrizeType) { + crate::prize::api::PrizeInternalImpl::set_prize_claimed(ref self, context_id, prize_type) + } + fn _set_prize_claimed_by_hash( + ref self: TContractState, context_id: u64, prize_type_hash: felt252, + ) { + crate::prize::api::PrizeInternalImpl::_set_prize_claimed_by_hash( + ref self, context_id, prize_type_hash, + ) + } + fn assert_prize_exists(self: @TContractState, prize_id: u64) { + crate::prize::api::PrizeInternalImpl::assert_prize_exists(self, prize_id) + } + fn assert_prize_not_claimed(self: @TContractState, context_id: u64, prize_type: PrizeType) { + crate::prize::api::PrizeInternalImpl::assert_prize_not_claimed(self, context_id, prize_type) + } + fn get_payout_position(self: @TContractState, prize_id: u64) -> u32 { + crate::prize::api::PrizeInternalImpl::get_payout_position(self, prize_id) + } + fn set_payout_position(ref self: TContractState, prize_id: u64, position: u32) { + crate::prize::api::PrizeInternalImpl::set_payout_position(ref self, prize_id, position) + } + fn _assert_prize_not_claimed_by_hash( + self: @TContractState, context_id: u64, prize_type_hash: felt252, + ) { + crate::prize::api::PrizeInternalImpl::_assert_prize_not_claimed_by_hash( + self, context_id, prize_type_hash, + ) + } + fn add_prize(ref self: TContractState, context_id: u64, prize: Prize) -> u64 { + crate::prize::api::PrizeInternalImpl::add_prize(ref self, context_id, prize) + } + fn _add_token_prize( + ref self: TContractState, context_id: u64, payload: TokenPrizePayload, + ) -> u64 { + crate::prize::api::PrizeInternalImpl::_add_token_prize(ref self, context_id, payload) + } + fn _set_extension( + ref self: TContractState, context_id: u64, prize_id: u64, ext: ExtensionConfig, + ) { + crate::prize::api::PrizeInternalImpl::_set_extension(ref self, context_id, prize_id, ext) + } + fn payout_prize_extension( + ref self: TContractState, + context_id: u64, + prize_id: u64, + token_id: Option, + payout_params: Span, + ) { + crate::prize::api::PrizeInternalImpl::payout_prize_extension( + ref self, context_id, prize_id, token_id, payout_params, + ) + } + fn payout_erc20( + ref self: TContractState, + token_address: ContractAddress, + amount: u128, + recipient: ContractAddress, + ) { + crate::prize::api::PrizeInternalImpl::payout_erc20( + ref self, token_address, amount, recipient, + ) + } + fn payout_erc721( + ref self: TContractState, + token_address: ContractAddress, + token_id: u128, + recipient: ContractAddress, + ) { + crate::prize::api::PrizeInternalImpl::payout_erc721( + ref self, token_address, token_id, recipient, + ) + } + fn refund_prize_erc20(ref self: TContractState, prize_id: u64, amount: u128) { + crate::prize::api::PrizeInternalImpl::refund_prize_erc20(ref self, prize_id, amount) + } + fn refund_prize_erc721(ref self: TContractState, prize_id: u64, token_id: u128) { + crate::prize::api::PrizeInternalImpl::refund_prize_erc721(ref self, prize_id, token_id) + } + fn get_extension_address( + self: @TContractState, context_id: u64, prize_id: u64, + ) -> ContractAddress { + crate::prize::api::PrizeInternalImpl::get_extension_address(self, context_id, prize_id) + } +} + +#[starknet::contract] +pub mod HostPrize { + use starknet::ContractAddress; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use crate::prize::store::Store; + use crate::prize::structs::{CustomShares, StoredPrize}; + #[storage] + struct Storage { + Prize_prizes: Map, + Prize_claims: Map<(u64, felt252), bool>, + Prize_total_prizes: u64, + Prize_custom_shares_packed: Map<(u64, u8), CustomShares>, + Prize_extension_address: Map<(u64, u64), ContractAddress>, + Prize_extension_prize_sponsor: Map, + metadata: Map, + } + pub impl PrizeHostStore of Store { + fn get_prize(self: @ContractState, prize_id: u64) -> StoredPrize { + self.Prize_prizes.entry(prize_id).read() + } + + fn set_prize(ref self: ContractState, prize_id: u64, prize: StoredPrize) { + self.Prize_prizes.entry(prize_id).write(prize); + } + + fn get_claim(self: @ContractState, context_id: u64, hash: felt252) -> bool { + self.Prize_claims.entry((context_id, hash)).read() + } + + fn set_claim(ref self: ContractState, context_id: u64, hash: felt252, claimed: bool) { + self.Prize_claims.entry((context_id, hash)).write(claimed); + } + + fn get_total_prizes(self: @ContractState) -> u64 { + self.Prize_total_prizes.read() + } + + fn set_total_prizes(ref self: ContractState, count: u64) { + self.Prize_total_prizes.write(count); + } + + fn get_custom_shares_count(self: @ContractState, prize_id: u64) -> u32 { + ((self.metadata.entry(prize_id).read() / 0x10000000000000000_u128) & 0xffffffff) + .try_into() + .unwrap() + } + + fn set_custom_shares_count(ref self: ContractState, prize_id: u64, count: u32) { + let old = self.metadata.entry(prize_id).read(); + let value: u128 = count.into(); + self + .metadata + .entry(prize_id) + .write(old - (old & 0xffffffff0000000000000000) + value * 0x10000000000000000_u128); + } + + fn get_custom_shares_packed(self: @ContractState, prize_id: u64, slot: u8) -> CustomShares { + self.Prize_custom_shares_packed.entry((prize_id, slot)).read() + } + + fn set_custom_shares_packed( + ref self: ContractState, prize_id: u64, slot: u8, shares: CustomShares, + ) { + self.Prize_custom_shares_packed.entry((prize_id, slot)).write(shares); + } + + fn get_extension_address( + self: @ContractState, context_id: u64, prize_id: u64, + ) -> ContractAddress { + self.Prize_extension_address.entry((context_id, prize_id)).read() + } + + fn set_extension_address( + ref self: ContractState, context_id: u64, prize_id: u64, addr: ContractAddress, + ) { + self.Prize_extension_address.entry((context_id, prize_id)).write(addr); + } + + fn get_extension_prize_context(self: @ContractState, prize_id: u64) -> u64 { + ((self.metadata.entry(prize_id).read() / 0x1_u128) & 0xffffffffffffffff) + .try_into() + .unwrap() + } + + fn set_extension_prize_context(ref self: ContractState, prize_id: u64, context_id: u64) { + let old = self.metadata.entry(prize_id).read(); + let value: u128 = context_id.into(); + self + .metadata + .entry(prize_id) + .write(old - (old & 0xffffffffffffffff) + value * 0x1_u128); + } + + fn get_extension_prize_sponsor(self: @ContractState, prize_id: u64) -> ContractAddress { + self.Prize_extension_prize_sponsor.entry(prize_id).read() + } + + fn set_extension_prize_sponsor( + ref self: ContractState, prize_id: u64, sponsor: ContractAddress, + ) { + self.Prize_extension_prize_sponsor.entry(prize_id).write(sponsor); + } + + fn get_payout_position(self: @ContractState, prize_id: u64) -> u32 { + ((self.metadata.entry(prize_id).read() / 0x1000000000000000000000000_u128) & 0xffffffff) + .try_into() + .unwrap() + } + + fn set_payout_position(ref self: ContractState, prize_id: u64, position: u32) { + let old = self.metadata.entry(prize_id).read(); + let value: u128 = position.into(); + self + .metadata + .entry(prize_id) + .write( + old + - (old & 0xffffffff000000000000000000000000) + + value * 0x1000000000000000000000000_u128, + ); + } + } + #[abi(embed_v0)] + impl Views = crate::prize::api::PrizeImpl; + #[abi(embed_v0)] + impl TestApi = super::HostPrizeApi; +} +#[starknet::contract] +pub mod AdapterPrize { + use crate::prize::prize_component::PrizeComponent; + component!(path: PrizeComponent, storage: prize, event: PrizeEvent); + #[storage] + struct Storage { + #[substorage(v0)] + prize: PrizeComponent::Storage, + } + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + PrizeEvent: PrizeComponent::Event, + } + impl PrizeStorage = crate::prize::storage_adapter::ComponentStore; + #[abi(embed_v0)] + impl Views = crate::prize::api::PrizeImpl; + #[abi(embed_v0)] + impl TestApi = super::HostPrizeApi; +} diff --git a/packages/metagame/src/prize/tests/test_host_storage.cairo b/packages/metagame/src/prize/tests/test_host_storage.cairo new file mode 100644 index 00000000..b9f5dc87 --- /dev/null +++ b/packages/metagame/src/prize/tests/test_host_storage.cairo @@ -0,0 +1,211 @@ +use core::num::traits::Zero; +use game_components_interfaces::prize::{IPrizeDispatcher, IPrizeDispatcherTrait}; +use game_components_utilities::distribution::structs::Distribution; +use snforge_std::trace::get_call_trace; +use snforge_std::{ + ContractClassTrait, DeclareResultTrait, declare, mock_call, start_cheat_caller_address, +}; +use starknet::ContractAddress; +use crate::prize::structs::{ + ERC20Data, ERC721Data, ExtensionPrizePayload, Prize, PrizeType, TokenPrizePayload, + TokenTypeData, +}; +use super::host_fixtures::{HostPrize, IHostPrizeDispatcher, IHostPrizeDispatcherTrait}; +fn addr(value: felt252) -> ContractAddress { + value.try_into().unwrap() +} +fn deploy(name: ByteArray) -> (IHostPrizeDispatcher, IPrizeDispatcher) { + let (contract_address, _) = declare(name).unwrap().contract_class().deploy(@array![]).unwrap(); + (IHostPrizeDispatcher { contract_address }, IPrizeDispatcher { contract_address }) +} +fn payload() -> TokenPrizePayload { + TokenPrizePayload { + token_address: addr(0xabc), + token_type: TokenTypeData::erc20( + ERC20Data { + amount: 1234, + distribution: Option::Some(Distribution::Custom(array![2500, 7500].span())), + distribution_count: Option::Some(2), + }, + ), + } +} +fn check_prize(name: ByteArray) { + let (host, views) = deploy(name); + assert!(views.get_total_prizes() == 0); + mock_call(addr(0xabc), selector!("transfer_from"), true, 1); + let id = host.add_prize(1, Prize::Token(payload())); + assert!(id == 1); + assert!(host._get_total_prizes() == 1); + let record = views.get_prize(id); + assert!(record.context_id == 1); + assert!(host._get_prize(id).context_id == 1); + assert!(*host._get_custom_shares(id).at(1) == 7500); + assert!(host._get_custom_share_at(id, 1) == 2500); + host.assert_prize_exists(id); + host.assert_prize_not_claimed(1, PrizeType::Distributed((id, 1))); + let hash = host.hash_prize_type(PrizeType::Distributed((id, 1))); + host._assert_prize_not_claimed_by_hash(1, hash); + host._set_prize_claimed_by_hash(1, hash); + assert!(host._is_prize_claimed_by_hash(1, hash)); + assert!(host._is_prize_claimed(1, PrizeType::Distributed((id, 1)))); + assert!(views.is_prize_claimed(1, PrizeType::Distributed((id, 1)))); + assert!(!views.is_prize_claimed(2, PrizeType::Distributed((id, 1)))); + assert!(!views.is_prize_claimed(1, PrizeType::Distributed((id, 2)))); + host.set_prize_claimed(1, PrizeType::Distributed((id, 2))); + host.set_payout_position(id, 12); + assert!(host.get_payout_position(id) == 12); + assert!(*host._get_custom_shares(id).at(1) == 7500); + assert!(host.get_extension_address(1, id).is_zero()); + mock_call(addr(0xabc), selector!("transfer"), true, 2); + host.payout_erc20(addr(0xabc), 100, addr(88)); + host.refund_prize_erc20(id, 200); + let next = host.increment_prize_count(); + host + .set_token_record( + next, + 2, + addr(99), + TokenPrizePayload { + token_address: addr(0xcba), + token_type: TokenTypeData::erc721(ERC721Data { id: 55 }), + }, + ); + assert!(views.get_prize(next).context_id == 2); + mock_call(addr(0xcba), selector!("transfer_from"), (), 2); + host.payout_erc721(addr(0xcba), 55, addr(88)); + host.refund_prize_erc721(next, 55); +} +#[test] +fn prize_host_storage_roundtrip_and_claims() { + check_prize("HostPrize"); +} +#[test] +fn prize_host_storage_component_adapter() { + check_prize("AdapterPrize"); +} +fn check_erc721_deposit(name: ByteArray) { + let (host, views) = deploy(name); + let sponsor = addr(99); + let nft = addr(0xcba); + let token_id = 0xffffffffffffffffffffffffffffffff_u128; + start_cheat_caller_address(host.contract_address, sponsor); + mock_call(nft, selector!("transfer_from"), (), 1); + let id = host + .add_prize( + 2, + Prize::Token( + TokenPrizePayload { + token_address: nft, + token_type: TokenTypeData::erc721(ERC721Data { id: token_id }), + }, + ), + ); + let trace = get_call_trace(); + let deposit = trace.nested_calls.at(trace.nested_calls.len() - 1); + assert!(*deposit.entry_point.entry_point_selector == selector!("add_prize")); + assert!(deposit.nested_calls.len() == 1); + let transfer = deposit.nested_calls.at(0); + assert!(*transfer.entry_point.contract_address == nft); + assert!(*transfer.entry_point.entry_point_selector == selector!("transfer_from")); + assert!( + transfer + .entry_point + .calldata == @array![sponsor.into(), host.contract_address.into(), token_id.into(), 0], + ); + assert!(id == 1); + assert!(views.get_total_prizes() == 1); + let record = views.get_prize(id); + assert!(record.context_id == 2); + assert!(record.sponsor_address == sponsor); + let deposited = match record.prize { + Prize::Token(payload) => payload, + _ => panic!("expected token prize"), + }; + assert!(deposited.token_address == nft); + match deposited.token_type { + TokenTypeData::erc721(data) => assert!(data.id == token_id), + _ => panic!("expected ERC721 prize"), + } +} +#[test] +fn prize_host_storage_erc721_deposit() { + check_erc721_deposit("HostPrize"); +} +#[test] +fn prize_host_storage_erc721_deposit_component_adapter() { + check_erc721_deposit("AdapterPrize"); +} +fn check_prize_extensions(name: ByteArray) { + let (host, views) = deploy(name); + let ext = addr(0xeee); + mock_call(ext, selector!("supports_interface"), true, 1); + mock_call(ext, selector!("add_prize"), (), 1); + let id = host + .add_prize( + 0xffffffffffffffff, + Prize::Extension(ExtensionPrizePayload { address: ext, config: array![99].span() }), + ); + host.set_payout_position(id, 0xffffffff); + mock_call(ext, selector!("get_config"), array![99].span(), 1); + let record = views.get_prize(id); + assert!(record.context_id == 0xffffffffffffffff); + assert!(host.get_payout_position(id) == 0xffffffff); + assert!(host.get_extension_address(0xffffffffffffffff, id) == ext); + let config = match record.prize { + Prize::Extension(payload) => payload.config, + _ => panic!("expected extension"), + }; + assert!(*config.at(0) == 99); + mock_call(ext, selector!("payout_prize"), (), 1); + host.payout_prize_extension(0xffffffffffffffff, id, Option::Some(77), array![18].span()); +} +#[test] +#[should_panic(expected: "Prize: Extension address cannot be zero")] +fn prize_host_storage_rejects_zero_extension() { + let (host, _) = deploy("HostPrize"); + let _ = host + .add_prize( + 1, + Prize::Extension(ExtensionPrizePayload { address: addr(0), config: array![].span() }), + ); +} +#[test] +#[should_panic(expected: "Prize: ERC20 transfer_from failed")] +fn prize_host_storage_rejects_failed_deposit() { + let (host, _) = deploy("HostPrize"); + mock_call(addr(0xabc), selector!("transfer_from"), false, 1); + let _ = host.add_prize(1, Prize::Token(payload())); +} + +#[test] +#[fuzzer(runs: 64)] +fn prize_host_storage_metadata_updates_preserve_other_fields( + context: u64, count: u32, position: u32, +) { + let mut state = HostPrize::contract_state_for_testing(); + HostPrize::PrizeHostStore::set_extension_prize_context(ref state, 1, context); + HostPrize::PrizeHostStore::set_custom_shares_count(ref state, 1, count); + HostPrize::PrizeHostStore::set_payout_position(ref state, 1, position); + assert!(HostPrize::PrizeHostStore::get_extension_prize_context(@state, 1) == context); + assert!(HostPrize::PrizeHostStore::get_custom_shares_count(@state, 1) == count); + assert!(HostPrize::PrizeHostStore::get_payout_position(@state, 1) == position); + HostPrize::PrizeHostStore::set_extension_prize_context(ref state, 1, 0); + assert!(HostPrize::PrizeHostStore::get_custom_shares_count(@state, 1) == count); + assert!(HostPrize::PrizeHostStore::get_payout_position(@state, 1) == position); + HostPrize::PrizeHostStore::set_custom_shares_count(ref state, 1, 0); + assert!(HostPrize::PrizeHostStore::get_payout_position(@state, 1) == position); + HostPrize::PrizeHostStore::set_payout_position(ref state, 1, 0); + assert!(HostPrize::PrizeHostStore::get_extension_prize_context(@state, 1) == 0); + assert!(HostPrize::PrizeHostStore::get_custom_shares_count(@state, 1) == 0); + assert!(HostPrize::PrizeHostStore::get_payout_position(@state, 1) == 0); +} + +#[test] +fn prize_host_storage_extensions_and_packed_metadata() { + check_prize_extensions("HostPrize"); +} +#[test] +fn prize_host_storage_extension_component_adapter() { + check_prize_extensions("AdapterPrize"); +} diff --git a/packages/metagame/src/registration.cairo b/packages/metagame/src/registration.cairo index a0608a28..42a0f6b9 100644 --- a/packages/metagame/src/registration.cairo +++ b/packages/metagame/src/registration.cairo @@ -1,8 +1,12 @@ +pub mod api; pub mod registration; pub mod registration_component; pub mod registration_store; +pub mod storage_adapter; pub mod store; pub mod structs; #[cfg(test)] mod tests; +#[cfg(test)] +pub use tests::host_fixtures; diff --git a/packages/metagame/src/registration/api.cairo b/packages/metagame/src/registration/api.cairo new file mode 100644 index 00000000..72c21df3 --- /dev/null +++ b/packages/metagame/src/registration/api.cairo @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Storage-independent APIs. The host supplies Store and enforces access control, +//! lifecycle eligibility and reentrancy protection before internal mutations. +use game_components_interfaces::registration::{IRegistration, Registration}; +use crate::registration::registration::registration::RegistrationValidationImpl; +use crate::registration::registration_store::{RegistrationStoreImpl, RegistrationStoreTrait}; +use crate::registration::store::Store; + + +#[starknet::embeddable] +pub impl RegistrationImpl< + TContractState, +Store, +Drop, +> of IRegistration { + fn get_entry(self: @TContractState, context_id: u64, entry_id: u32) -> Registration { + RegistrationStoreTrait::get_entry(self, context_id, entry_id) + } + + fn entry_exists(self: @TContractState, context_id: u64, entry_id: u32) -> bool { + RegistrationStoreTrait::entry_exists(self, context_id, entry_id) + } + + fn is_token_banned(self: @TContractState, context_id: u64, token_id: felt252) -> bool { + RegistrationStoreTrait::is_token_banned(self, context_id, token_id) + } + + fn get_entry_count(self: @TContractState, context_id: u64) -> u32 { + Store::get_entry_count(self, context_id) + } +} + +#[generate_trait] +pub impl RegistrationInternalImpl< + TContractState, +Store, +Drop, +> of RegistrationInternalTrait { + fn _get_entry(self: @TContractState, context_id: u64, entry_id: u32) -> Registration { + RegistrationStoreTrait::get_entry(self, context_id, entry_id) + } + + fn set_entry(ref self: TContractState, registration: @Registration) { + RegistrationStoreTrait::set_entry(ref self, registration); + } + + fn _get_entry_count(self: @TContractState, context_id: u64) -> u32 { + Store::get_entry_count(self, context_id) + } + + fn increment_entry_count(ref self: TContractState, context_id: u64) -> u32 { + RegistrationStoreTrait::increment_entry_count(ref self, context_id) + } + + fn mark_token_submitted(ref self: TContractState, context_id: u64, token_id: felt252) { + RegistrationStoreTrait::mark_token_submitted(ref self, context_id, token_id); + } + + fn ban_token(ref self: TContractState, context_id: u64, token_id: felt252) { + RegistrationStoreTrait::ban_token(ref self, context_id, token_id); + } + + fn _entry_exists(self: @TContractState, context_id: u64, entry_id: u32) -> bool { + RegistrationStoreTrait::entry_exists(self, context_id, entry_id) + } + + fn assert_valid_for_submission( + self: @TContractState, registration: @Registration, context_id: u64, + ) { + RegistrationValidationImpl::assert_valid_for_submission(registration, context_id); + } + + fn _get_token_context(self: @TContractState, context_id: u64, token_id: felt252) -> u64 { + RegistrationStoreTrait::get_token_context(self, context_id, token_id) + } + + fn _is_token_submitted(self: @TContractState, context_id: u64, token_id: felt252) -> bool { + RegistrationStoreTrait::is_token_submitted(self, context_id, token_id) + } + + fn _is_token_banned(self: @TContractState, context_id: u64, token_id: felt252) -> bool { + RegistrationStoreTrait::is_token_banned(self, context_id, token_id) + } +} diff --git a/packages/metagame/src/registration/registration_component.cairo b/packages/metagame/src/registration/registration_component.cairo index 9bb1c13e..511b20df 100644 --- a/packages/metagame/src/registration/registration_component.cairo +++ b/packages/metagame/src/registration/registration_component.cairo @@ -35,8 +35,6 @@ pub mod RegistrationComponent { use starknet::storage::{ Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, }; - use crate::registration::registration::registration::RegistrationValidationImpl; - use crate::registration::registration_store::{RegistrationStoreImpl, RegistrationStoreTrait}; use crate::registration::store::Store; #[storage] @@ -104,23 +102,23 @@ pub mod RegistrationComponent { fn get_entry( self: @ComponentState, context_id: u64, entry_id: u32, ) -> Registration { - RegistrationStoreTrait::get_entry(self, context_id, entry_id) + crate::registration::api::RegistrationImpl::get_entry(self, context_id, entry_id) } fn entry_exists( self: @ComponentState, context_id: u64, entry_id: u32, ) -> bool { - RegistrationStoreTrait::entry_exists(self, context_id, entry_id) + crate::registration::api::RegistrationImpl::entry_exists(self, context_id, entry_id) } fn is_token_banned( self: @ComponentState, context_id: u64, token_id: felt252, ) -> bool { - RegistrationStoreTrait::is_token_banned(self, context_id, token_id) + crate::registration::api::RegistrationImpl::is_token_banned(self, context_id, token_id) } fn get_entry_count(self: @ComponentState, context_id: u64) -> u32 { - Store::get_entry_count(self, context_id) + crate::registration::api::RegistrationImpl::get_entry_count(self, context_id) } } @@ -131,59 +129,77 @@ pub mod RegistrationComponent { fn _get_entry( self: @ComponentState, context_id: u64, entry_id: u32, ) -> Registration { - RegistrationStoreTrait::get_entry(self, context_id, entry_id) + crate::registration::api::RegistrationInternalImpl::_get_entry( + self, context_id, entry_id, + ) } fn set_entry(ref self: ComponentState, registration: @Registration) { - RegistrationStoreTrait::set_entry(ref self, registration); + crate::registration::api::RegistrationInternalImpl::set_entry(ref self, registration) } fn _get_entry_count(self: @ComponentState, context_id: u64) -> u32 { - Store::get_entry_count(self, context_id) + crate::registration::api::RegistrationInternalImpl::_get_entry_count(self, context_id) } fn increment_entry_count(ref self: ComponentState, context_id: u64) -> u32 { - RegistrationStoreTrait::increment_entry_count(ref self, context_id) + crate::registration::api::RegistrationInternalImpl::increment_entry_count( + ref self, context_id, + ) } fn mark_token_submitted( ref self: ComponentState, context_id: u64, token_id: felt252, ) { - RegistrationStoreTrait::mark_token_submitted(ref self, context_id, token_id); + crate::registration::api::RegistrationInternalImpl::mark_token_submitted( + ref self, context_id, token_id, + ) } fn ban_token(ref self: ComponentState, context_id: u64, token_id: felt252) { - RegistrationStoreTrait::ban_token(ref self, context_id, token_id); + crate::registration::api::RegistrationInternalImpl::ban_token( + ref self, context_id, token_id, + ) } fn _entry_exists( self: @ComponentState, context_id: u64, entry_id: u32, ) -> bool { - RegistrationStoreTrait::entry_exists(self, context_id, entry_id) + crate::registration::api::RegistrationInternalImpl::_entry_exists( + self, context_id, entry_id, + ) } fn assert_valid_for_submission( self: @ComponentState, registration: @Registration, context_id: u64, ) { - RegistrationValidationImpl::assert_valid_for_submission(registration, context_id); + crate::registration::api::RegistrationInternalImpl::assert_valid_for_submission( + self, registration, context_id, + ) } fn _get_token_context( self: @ComponentState, context_id: u64, token_id: felt252, ) -> u64 { - RegistrationStoreTrait::get_token_context(self, context_id, token_id) + crate::registration::api::RegistrationInternalImpl::_get_token_context( + self, context_id, token_id, + ) } fn _is_token_submitted( self: @ComponentState, context_id: u64, token_id: felt252, ) -> bool { - RegistrationStoreTrait::is_token_submitted(self, context_id, token_id) + crate::registration::api::RegistrationInternalImpl::_is_token_submitted( + self, context_id, token_id, + ) } fn _is_token_banned( self: @ComponentState, context_id: u64, token_id: felt252, ) -> bool { - RegistrationStoreTrait::is_token_banned(self, context_id, token_id) + crate::registration::api::RegistrationInternalImpl::_is_token_banned( + self, context_id, token_id, + ) } } } diff --git a/packages/metagame/src/registration/storage_adapter.cairo b/packages/metagame/src/registration/storage_adapter.cairo new file mode 100644 index 00000000..a3376f44 --- /dev/null +++ b/packages/metagame/src/registration/storage_adapter.cairo @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: BUSL-1.1 +//! Optional adapter retaining the original component storage maps. +use crate::registration::registration_component::RegistrationComponent; +use crate::registration::store::Store; + +pub impl ComponentStore, +Drop> of Store { + fn get_token_id(self: @T, context_id: u64, entry_id: u32) -> felt252 { + Store::get_token_id( + RegistrationComponent::HasComponent::get_component(self), context_id, entry_id, + ) + } + fn set_token_id(ref self: T, context_id: u64, entry_id: u32, token_id: felt252) { + let mut component = RegistrationComponent::HasComponent::get_component_mut(ref self); + Store::set_token_id(ref component, context_id, entry_id, token_id); + } + fn get_entry_count(self: @T, context_id: u64) -> u32 { + Store::get_entry_count(RegistrationComponent::HasComponent::get_component(self), context_id) + } + fn set_entry_count(ref self: T, context_id: u64, count: u32) { + let mut component = RegistrationComponent::HasComponent::get_component_mut(ref self); + Store::set_entry_count(ref component, context_id, count); + } + fn get_token_state_raw(self: @T, context_id: u64, token_id: felt252) -> felt252 { + Store::get_token_state_raw( + RegistrationComponent::HasComponent::get_component(self), context_id, token_id, + ) + } + fn set_token_state_raw(ref self: T, context_id: u64, token_id: felt252, state: felt252) { + let mut component = RegistrationComponent::HasComponent::get_component_mut(ref self); + Store::set_token_state_raw(ref component, context_id, token_id, state); + } +} diff --git a/packages/metagame/src/registration/tests.cairo b/packages/metagame/src/registration/tests.cairo index 055f098b..77c44c21 100644 --- a/packages/metagame/src/registration/tests.cairo +++ b/packages/metagame/src/registration/tests.cairo @@ -1,3 +1,5 @@ +pub mod host_fixtures; pub mod mocks; +mod test_host_storage; mod test_models; mod test_registration_component; diff --git a/packages/metagame/src/registration/tests/host_fixtures.cairo b/packages/metagame/src/registration/tests/host_fixtures.cairo new file mode 100644 index 00000000..3409eb4f --- /dev/null +++ b/packages/metagame/src/registration/tests/host_fixtures.cairo @@ -0,0 +1,377 @@ +use game_components_interfaces::registration::Registration; +use metagame_extensions_interfaces::extension::ExtensionConfig; +use starknet::ContractAddress; +use crate::entry_fee::structs::{ + AdditionalShare, EntryFee, EntryFeeClaimType, EntryFeeConfig, EntryFeeDeposit, +}; + +#[starknet::interface] +pub trait IHostRegistrationFee { + fn _get_entry(self: @T, context_id: u64, entry_id: u32) -> Registration; + fn set_entry(ref self: T, registration: Registration); + fn _get_entry_count(self: @T, context_id: u64) -> u32; + fn increment_entry_count(ref self: T, context_id: u64) -> u32; + fn mark_token_submitted(ref self: T, context_id: u64, token_id: felt252); + fn ban_token(ref self: T, context_id: u64, token_id: felt252); + fn _entry_exists(self: @T, context_id: u64, entry_id: u32) -> bool; + fn assert_valid_for_submission(self: @T, registration: Registration, context_id: u64); + fn _get_token_context(self: @T, context_id: u64, token_id: felt252) -> u64; + fn _is_token_submitted(self: @T, context_id: u64, token_id: felt252) -> bool; + fn _is_token_banned(self: @T, context_id: u64, token_id: felt252) -> bool; + fn _get_entry_fee(self: @T, context_id: u64) -> Option; + fn _get_additional_shares(self: @T, context_id: u64) -> Span; + fn _store_distribution_shares(ref self: T, context_id: u64, shares: Span); + fn _get_distribution_shares(self: @T, context_id: u64, count: u32) -> Array; + fn _get_custom_share_at(self: @T, context_id: u64, position: u32) -> u16; + fn set_entry_fee(ref self: T, context_id: u64, entry_fee: EntryFee) -> Option; + fn _set_entry_fee_config(ref self: T, context_id: u64, config: EntryFeeConfig); + fn _set_extension(ref self: T, context_id: u64, ext: ExtensionConfig); + fn deposit_entry_fee(ref self: T, context_id: u64, deposit: EntryFeeDeposit); + fn payout_entry_fee_extension( + ref self: T, context_id: u64, token_id: Option, claim_params: Span, + ); + fn get_entry_fee_extension_config( + self: @T, context_owner: ContractAddress, context_id: u64, + ) -> Span; + fn payout( + ref self: T, token_address: ContractAddress, recipient: ContractAddress, amount: u128, + ); + fn is_claimed(self: @T, context_id: u64, claim_type: EntryFeeClaimType) -> bool; + fn set_claimed(ref self: T, context_id: u64, claim_type: EntryFeeClaimType); + fn get_extension_address(self: @T, context_id: u64) -> ContractAddress; +} + +#[starknet::embeddable] +pub impl HostRegistrationFeeApi< + TContractState, + +crate::registration::store::Store, + +crate::entry_fee::store::Store, + +Drop, +> of IHostRegistrationFee { + fn _get_entry(self: @TContractState, context_id: u64, entry_id: u32) -> Registration { + crate::registration::api::RegistrationInternalImpl::_get_entry(self, context_id, entry_id) + } + fn set_entry(ref self: TContractState, registration: Registration) { + crate::registration::api::RegistrationInternalImpl::set_entry(ref self, @registration) + } + fn _get_entry_count(self: @TContractState, context_id: u64) -> u32 { + crate::registration::api::RegistrationInternalImpl::_get_entry_count(self, context_id) + } + fn increment_entry_count(ref self: TContractState, context_id: u64) -> u32 { + crate::registration::api::RegistrationInternalImpl::increment_entry_count( + ref self, context_id, + ) + } + fn mark_token_submitted(ref self: TContractState, context_id: u64, token_id: felt252) { + crate::registration::api::RegistrationInternalImpl::mark_token_submitted( + ref self, context_id, token_id, + ) + } + fn ban_token(ref self: TContractState, context_id: u64, token_id: felt252) { + crate::registration::api::RegistrationInternalImpl::ban_token( + ref self, context_id, token_id, + ) + } + fn _entry_exists(self: @TContractState, context_id: u64, entry_id: u32) -> bool { + crate::registration::api::RegistrationInternalImpl::_entry_exists( + self, context_id, entry_id, + ) + } + fn assert_valid_for_submission( + self: @TContractState, registration: Registration, context_id: u64, + ) { + crate::registration::api::RegistrationInternalImpl::assert_valid_for_submission( + self, @registration, context_id, + ) + } + fn _get_token_context(self: @TContractState, context_id: u64, token_id: felt252) -> u64 { + crate::registration::api::RegistrationInternalImpl::_get_token_context( + self, context_id, token_id, + ) + } + fn _is_token_submitted(self: @TContractState, context_id: u64, token_id: felt252) -> bool { + crate::registration::api::RegistrationInternalImpl::_is_token_submitted( + self, context_id, token_id, + ) + } + fn _is_token_banned(self: @TContractState, context_id: u64, token_id: felt252) -> bool { + crate::registration::api::RegistrationInternalImpl::_is_token_banned( + self, context_id, token_id, + ) + } + fn _get_entry_fee(self: @TContractState, context_id: u64) -> Option { + crate::entry_fee::api::EntryFeeInternalImpl::_get_entry_fee(self, context_id) + } + fn _get_additional_shares(self: @TContractState, context_id: u64) -> Span { + crate::entry_fee::api::EntryFeeInternalImpl::_get_additional_shares(self, context_id) + } + fn _store_distribution_shares(ref self: TContractState, context_id: u64, shares: Span) { + crate::entry_fee::api::EntryFeeInternalImpl::_store_distribution_shares( + ref self, context_id, shares, + ) + } + fn _get_distribution_shares(self: @TContractState, context_id: u64, count: u32) -> Array { + crate::entry_fee::api::EntryFeeInternalImpl::_get_distribution_shares( + self, context_id, count, + ) + } + fn _get_custom_share_at(self: @TContractState, context_id: u64, position: u32) -> u16 { + crate::entry_fee::api::EntryFeeInternalImpl::_get_custom_share_at( + self, context_id, position, + ) + } + fn set_entry_fee( + ref self: TContractState, context_id: u64, entry_fee: EntryFee, + ) -> Option { + crate::entry_fee::api::EntryFeeInternalImpl::set_entry_fee(ref self, context_id, entry_fee) + } + fn _set_entry_fee_config(ref self: TContractState, context_id: u64, config: EntryFeeConfig) { + crate::entry_fee::api::EntryFeeInternalImpl::_set_entry_fee_config( + ref self, context_id, @config, + ) + } + fn _set_extension(ref self: TContractState, context_id: u64, ext: ExtensionConfig) { + crate::entry_fee::api::EntryFeeInternalImpl::_set_extension(ref self, context_id, ext) + } + fn deposit_entry_fee(ref self: TContractState, context_id: u64, deposit: EntryFeeDeposit) { + crate::entry_fee::api::EntryFeeInternalImpl::deposit_entry_fee( + ref self, context_id, deposit, + ) + } + fn payout_entry_fee_extension( + ref self: TContractState, + context_id: u64, + token_id: Option, + claim_params: Span, + ) { + crate::entry_fee::api::EntryFeeInternalImpl::payout_entry_fee_extension( + ref self, context_id, token_id, claim_params, + ) + } + fn get_entry_fee_extension_config( + self: @TContractState, context_owner: ContractAddress, context_id: u64, + ) -> Span { + crate::entry_fee::api::EntryFeeInternalImpl::get_entry_fee_extension_config( + self, context_owner, context_id, + ) + } + fn payout( + ref self: TContractState, + token_address: ContractAddress, + recipient: ContractAddress, + amount: u128, + ) { + crate::entry_fee::api::EntryFeeInternalImpl::payout( + ref self, token_address, recipient, amount, + ) + } + fn is_claimed(self: @TContractState, context_id: u64, claim_type: EntryFeeClaimType) -> bool { + crate::entry_fee::api::EntryFeeInternalImpl::is_claimed(self, context_id, claim_type) + } + fn set_claimed(ref self: TContractState, context_id: u64, claim_type: EntryFeeClaimType) { + crate::entry_fee::api::EntryFeeInternalImpl::set_claimed(ref self, context_id, claim_type) + } + fn get_extension_address(self: @TContractState, context_id: u64) -> ContractAddress { + crate::entry_fee::api::EntryFeeInternalImpl::get_extension_address(self, context_id) + } +} + +#[starknet::contract] +pub mod HostRegistrationFee { + use game_components_utilities::distribution::packed_shares::CustomShares; + use game_components_utilities::distribution::structs::PackedDistribution; + use starknet::ContractAddress; + use starknet::storage::{ + Map, StoragePathEntry, StoragePointerReadAccess, StoragePointerWriteAccess, + }; + use crate::entry_fee::store::Store as FeeStore; + use crate::entry_fee::structs::PackedAdditionalShares; + use crate::registration::store::Store as RegistrationStore; + #[storage] + struct Storage { + Registration_token_ids: Map<(u64, u32), felt252>, + Registration_entry_counts: Map, + EntryFee_token: Map, + EntryFee_data: Map, + EntryFee_additional_recipient: Map<(u64, u8), ContractAddress>, + EntryFee_additional_shares_packed: Map<(u64, u8), PackedAdditionalShares>, + EntryFee_distribution_shares_packed: Map<(u64, u8), CustomShares>, + EntryFee_distribution: Map, + EntryFee_position_claimed: Map<(u64, u32), bool>, + EntryFee_extension_address: Map, + entry_state: Map<(u64, felt252), u128>, + } + pub impl RegistrationHostStore of RegistrationStore { + fn get_token_id(self: @ContractState, context_id: u64, entry_id: u32) -> felt252 { + self.Registration_token_ids.entry((context_id, entry_id)).read() + } + + fn set_token_id( + ref self: ContractState, context_id: u64, entry_id: u32, token_id: felt252, + ) { + self.Registration_token_ids.entry((context_id, entry_id)).write(token_id); + } + + fn get_entry_count(self: @ContractState, context_id: u64) -> u32 { + self.Registration_entry_counts.entry(context_id).read() + } + + fn set_entry_count(ref self: ContractState, context_id: u64, count: u32) { + self.Registration_entry_counts.entry(context_id).write(count); + } + + fn get_token_state_raw( + self: @ContractState, context_id: u64, token_id: felt252, + ) -> felt252 { + (self.entry_state.entry((context_id, token_id)).read() & 0x3ffffffffffffffff).into() + } + + fn set_token_state_raw( + ref self: ContractState, context_id: u64, token_id: felt252, state: felt252, + ) { + let old = self.entry_state.entry((context_id, token_id)).read(); + let raw: u128 = state.try_into().unwrap(); + assert!(raw <= 0x3ffffffffffffffff, "invalid registration bits"); + self.entry_state.entry((context_id, token_id)).write((old & 0x40000000000000000) | raw); + } + } + pub impl EntryFeeHostStore of FeeStore { + fn get_token(self: @ContractState, context_id: u64) -> ContractAddress { + self.EntryFee_token.entry(context_id).read() + } + + fn set_token(ref self: ContractState, context_id: u64, token: ContractAddress) { + self.EntryFee_token.entry(context_id).write(token); + } + + fn get_data_raw(self: @ContractState, context_id: u64) -> felt252 { + self.EntryFee_data.entry(context_id).read() + } + + fn set_data_raw(ref self: ContractState, context_id: u64, data: felt252) { + self.EntryFee_data.entry(context_id).write(data); + } + + fn get_additional_recipient( + self: @ContractState, context_id: u64, index: u8, + ) -> ContractAddress { + self.EntryFee_additional_recipient.entry((context_id, index)).read() + } + + fn set_additional_recipient( + ref self: ContractState, context_id: u64, index: u8, recipient: ContractAddress, + ) { + self.EntryFee_additional_recipient.entry((context_id, index)).write(recipient); + } + + fn get_packed_shares( + self: @ContractState, context_id: u64, slot: u8, + ) -> PackedAdditionalShares { + self.EntryFee_additional_shares_packed.entry((context_id, slot)).read() + } + + fn set_packed_shares( + ref self: ContractState, context_id: u64, slot: u8, shares: PackedAdditionalShares, + ) { + self.EntryFee_additional_shares_packed.entry((context_id, slot)).write(shares); + } + + fn get_refund_claimed(self: @ContractState, context_id: u64, token_id: felt252) -> bool { + (self.entry_state.entry((context_id, token_id)).read() & 0x40000000000000000) != 0 + } + + fn set_refund_claimed( + ref self: ContractState, context_id: u64, token_id: felt252, claimed: bool, + ) { + let old = self.entry_state.entry((context_id, token_id)).read(); + let flag = if claimed { + 0x40000000000000000 + } else { + 0 + }; + self + .entry_state + .entry((context_id, token_id)) + .write((old & 0x3ffffffffffffffff) | flag); + } + + fn get_position_claimed(self: @ContractState, context_id: u64, position: u32) -> bool { + self.EntryFee_position_claimed.entry((context_id, position)).read() + } + + fn set_position_claimed( + ref self: ContractState, context_id: u64, position: u32, claimed: bool, + ) { + self.EntryFee_position_claimed.entry((context_id, position)).write(claimed); + } + + fn get_extension_address(self: @ContractState, context_id: u64) -> ContractAddress { + self.EntryFee_extension_address.entry(context_id).read() + } + + fn set_extension_address( + ref self: ContractState, context_id: u64, address: ContractAddress, + ) { + self.EntryFee_extension_address.entry(context_id).write(address); + } + + fn get_distribution_shares_packed( + self: @ContractState, context_id: u64, slot: u8, + ) -> CustomShares { + self.EntryFee_distribution_shares_packed.entry((context_id, slot)).read() + } + + fn set_distribution_shares_packed( + ref self: ContractState, context_id: u64, slot: u8, shares: CustomShares, + ) { + self.EntryFee_distribution_shares_packed.entry((context_id, slot)).write(shares); + } + + fn get_distribution(self: @ContractState, context_id: u64) -> PackedDistribution { + self.EntryFee_distribution.entry(context_id).read() + } + + fn set_distribution( + ref self: ContractState, context_id: u64, distribution: PackedDistribution, + ) { + self.EntryFee_distribution.entry(context_id).write(distribution); + } + } + #[abi(embed_v0)] + impl RegistrationViews = + crate::registration::api::RegistrationImpl; + #[abi(embed_v0)] + impl FeeViews = crate::entry_fee::api::EntryFeeImpl; + #[abi(embed_v0)] + impl TestApi = super::HostRegistrationFeeApi; +} + +#[starknet::contract] +pub mod AdapterRegistrationFee { + use crate::entry_fee::entry_fee_component::EntryFeeComponent; + use crate::registration::registration_component::RegistrationComponent; + component!(path: RegistrationComponent, storage: registration, event: RegistrationEvent); + component!(path: EntryFeeComponent, storage: fee, event: FeeEvent); + #[storage] + struct Storage { + #[substorage(v0)] + registration: RegistrationComponent::Storage, + #[substorage(v0)] + fee: EntryFeeComponent::Storage, + } + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + RegistrationEvent: RegistrationComponent::Event, + FeeEvent: EntryFeeComponent::Event, + } + impl RegistrationStorage = crate::registration::storage_adapter::ComponentStore; + impl FeeStorage = crate::entry_fee::storage_adapter::ComponentStore; + #[abi(embed_v0)] + impl RegistrationViews = + crate::registration::api::RegistrationImpl; + #[abi(embed_v0)] + impl FeeViews = crate::entry_fee::api::EntryFeeImpl; + #[abi(embed_v0)] + impl TestApi = super::HostRegistrationFeeApi; +} diff --git a/packages/metagame/src/registration/tests/test_host_storage.cairo b/packages/metagame/src/registration/tests/test_host_storage.cairo new file mode 100644 index 00000000..4eed7038 --- /dev/null +++ b/packages/metagame/src/registration/tests/test_host_storage.cairo @@ -0,0 +1,151 @@ +use game_components_interfaces::registration::{ + IRegistrationDispatcher, IRegistrationDispatcherTrait, Registration, +}; +use snforge_std::{ContractClassTrait, DeclareResultTrait, declare}; +use crate::entry_fee::structs::EntryFeeClaimType; +use super::host_fixtures::{ + HostRegistrationFee, IHostRegistrationFeeDispatcher, IHostRegistrationFeeDispatcherTrait, +}; + +fn deploy(name: ByteArray) -> (IHostRegistrationFeeDispatcher, IRegistrationDispatcher) { + let (contract_address, _) = declare(name).unwrap().contract_class().deploy(@array![]).unwrap(); + ( + IHostRegistrationFeeDispatcher { contract_address }, + IRegistrationDispatcher { contract_address }, + ) +} +fn check_registration(name: ByteArray) { + let (host, views) = deploy(name); + assert!(views.get_entry_count(1) == 0); + assert!(!views.entry_exists(1, 1)); + assert!(host._get_token_context(1, 77) == 0); + assert!(host.increment_entry_count(1) == 1); + host + .set_entry( + Registration { + context_id: 1, + entry_id: 1, + game_token_id: 77, + has_submitted: false, + is_banned: false, + }, + ); + assert!(views.entry_exists(1, 1)); + assert!(host._get_entry_count(1) == 1); + assert!(host._entry_exists(1, 1)); + assert!(host._get_entry(1, 1).game_token_id == 77); + assert!(host._get_token_context(1, 77) == 1); + assert!(host._get_token_context(2, 77) == 0); + host.assert_valid_for_submission(views.get_entry(1, 1), 1); + host.mark_token_submitted(1, 77); + host.set_claimed(1, EntryFeeClaimType::Refund(77)); + host.ban_token(1, 77); + assert!(host._is_token_submitted(1, 77)); + assert!(host._is_token_banned(1, 77)); + assert!(views.is_token_banned(1, 77)); + assert!(host.is_claimed(1, EntryFeeClaimType::Refund(77))); + // The refund bit belongs to fee accounting, even if registration overwrites the slot. + host + .set_entry( + Registration { + context_id: 1, + entry_id: 1, + game_token_id: 88, + has_submitted: false, + is_banned: false, + }, + ); + assert!(host._get_token_context(1, 77) == 0); + assert!(!host._is_token_submitted(1, 77)); + assert!(!host._is_token_banned(1, 77)); + assert!(host.is_claimed(1, EntryFeeClaimType::Refund(77))); + assert!(!host.is_claimed(1, EntryFeeClaimType::Refund(88))); + assert!(!host.is_claimed(2, EntryFeeClaimType::Refund(77))); + host + .set_entry( + Registration { + context_id: 2, entry_id: 1, game_token_id: 88, has_submitted: true, is_banned: true, + }, + ); + assert!(!views.get_entry(1, 1).is_banned); + assert!(views.get_entry(2, 1).is_banned); +} +#[test] +fn registration_host_storage_preserves_refunds_and_contexts() { + check_registration("HostRegistrationFee"); +} +#[test] +fn registration_host_storage_component_adapter() { + check_registration("AdapterRegistrationFee"); +} +#[test] +#[fuzzer(runs: 64)] +fn registration_host_storage_combined_flags( + context: u64, submitted: bool, banned: bool, refunded: bool, +) { + let id = if context == 0 { + 1 + } else { + context + }; + let (host, views) = deploy("HostRegistrationFee"); + if refunded { + host.set_claimed(id, EntryFeeClaimType::Refund(11)); + } + host + .set_entry( + Registration { + context_id: id, + entry_id: 1, + game_token_id: 11, + has_submitted: submitted, + is_banned: banned, + }, + ); + let entry = views.get_entry(id, 1); + assert!(entry.context_id == id); + assert!(entry.has_submitted == submitted); + assert!(entry.is_banned == banned); + assert!(host.is_claimed(id, EntryFeeClaimType::Refund(11)) == refunded); + host.mark_token_submitted(id, 11); + host.ban_token(id, 11); + assert!(host.is_claimed(id, EntryFeeClaimType::Refund(11)) == refunded); + assert!(views.get_entry(id, 1).has_submitted); + assert!(views.get_entry(id, 1).is_banned); +} +#[test] +#[should_panic(expected: ('Invalid token id', 'ENTRYPOINT_FAILED'))] +fn registration_host_storage_rejects_zero_token() { + let (host, _) = deploy("HostRegistrationFee"); + host + .set_entry( + Registration { + context_id: 1, + entry_id: 1, + game_token_id: 0, + has_submitted: false, + is_banned: false, + }, + ); +} + +#[test] +fn registration_host_storage_clearing_refund_preserves_registration() { + let mut state = HostRegistrationFee::contract_state_for_testing(); + HostRegistrationFee::RegistrationHostStore::set_token_state_raw( + ref state, 1, 77, 0x30000000000000001, + ); + HostRegistrationFee::EntryFeeHostStore::set_refund_claimed(ref state, 1, 77, true); + assert!( + HostRegistrationFee::RegistrationHostStore::get_token_state_raw( + @state, 1, 77, + ) == 0x30000000000000001, + ); + HostRegistrationFee::EntryFeeHostStore::set_refund_claimed(ref state, 1, 77, false); + assert!( + HostRegistrationFee::RegistrationHostStore::get_token_state_raw( + @state, 1, 77, + ) == 0x30000000000000001, + ); + assert!(!HostRegistrationFee::EntryFeeHostStore::get_refund_claimed(@state, 1, 77)); +}