diff --git a/contracts/tokens/Cargo.toml b/contracts/tokens/Cargo.toml new file mode 100644 index 00000000..c7173f0f --- /dev/null +++ b/contracts/tokens/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "tokens" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib", "cdylib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[[test]] +name = "limits" +path = "tests/limits.rs" diff --git a/contracts/tokens/README.md b/contracts/tokens/README.md new file mode 100644 index 00000000..b652afd3 --- /dev/null +++ b/contracts/tokens/README.md @@ -0,0 +1,61 @@ +# Tokens Contract + +The `tokens` contract bounds token-adjacent state stored for each account. +Authenticated accounts may track and untrack unique 32-byte identifiers for: + +- bets; +- positions; +- subscriptions. + +## Per-account limits + +`AccountLimits` configures independent caps for each category. The same limits +apply separately to every account, and every configured value must be no larger +than `MAX_CONFIGURABLE_ACCOUNT_LIMIT` (`256`). + +Tracking an item: + +1. requires authentication from the affected account; +2. rejects duplicate `(account, category, item_id)` tuples; +3. increments usage with `checked_add`; +4. verifies the next count does not exceed the configured category cap; +5. stores the bounded category membership set only after every validation + succeeds. + +Untracking requires the exact stored item and uses `checked_sub`, so a fabricated +identifier cannot release capacity. + +Each account/category membership set is stored as one persistent entry. Its +membership and count therefore share a TTL and cannot drift if an old entry +expires. + +## Public API changes + +The crate adds `TokensContract` with these state-changing entrypoints: + +| Entrypoint | Required signer | Purpose | +|---|---|---| +| `initialize` | Initial admin | Set admin and limits | +| `set_account_limits` | Current admin | Replace global per-account caps | +| `track_account_item` | Affected account | Add one bounded item | +| `untrack_account_item` | Affected account | Remove one exact item | + +Read-only views: + +- `get_account_limits` +- `get_account_usage` +- `get_remaining_capacity` +- `is_account_item_tracked` +- `get_admin` + +Lowering a cap below existing usage does not delete account state. Remaining +capacity is reported as zero, and new items in that category are rejected until +usage falls below the new cap. + +## Testing + +Run the focused suite with: + +```bash +cargo test -p tokens +``` diff --git a/contracts/tokens/src/lib.rs b/contracts/tokens/src/lib.rs new file mode 100644 index 00000000..40306338 --- /dev/null +++ b/contracts/tokens/src/lib.rs @@ -0,0 +1,173 @@ +//! Token account-state registry with bounded per-account storage. +//! +//! The contract tracks identifiers for account-owned bets, positions, and +//! subscriptions. Each category has an administrator-configured cap that is +//! enforced independently for every account. +//! +//! All state-changing entrypoints require authentication from the acting +//! address. Read-only views are public. + +#![no_std] + +mod limits; + +pub use limits::{ + AccountLimits, AccountStateKind, AccountUsage, TokenLimitError, MAX_CONFIGURABLE_ACCOUNT_LIMIT, +}; + +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env}; + +/// Token account-state contract. +#[contract] +pub struct TokensContract; + +#[contractimpl] +impl TokensContract { + /// Initializes the contract administrator and global per-account limits. + /// + /// The limits apply independently to every account. A zero limit disables + /// new entries for that category. + /// + /// # Authorization + /// + /// `admin` must authorize this invocation. + /// + /// # Errors + /// + /// - [`TokenLimitError::AlreadyInitialized`] if initialization already ran. + /// - [`TokenLimitError::InvalidLimit`] if any configured cap exceeds + /// [`MAX_CONFIGURABLE_ACCOUNT_LIMIT`]. + pub fn initialize( + env: Env, + admin: Address, + account_limits: AccountLimits, + ) -> Result<(), TokenLimitError> { + admin.require_auth(); + limits::initialize(&env, &admin, &account_limits) + } + + /// Replaces the global limits applied independently to every account. + /// + /// Existing entries are never deleted when a cap is lowered. An account + /// already above a new cap cannot track another item in that category until + /// enough existing items are removed. + /// + /// # Authorization + /// + /// `admin` must authorize this invocation and match the stored + /// administrator. + /// + /// # Errors + /// + /// - [`TokenLimitError::NotInitialized`] if the contract is not initialized. + /// - [`TokenLimitError::Unauthorized`] if `admin` is not the administrator. + /// - [`TokenLimitError::InvalidLimit`] if any configured cap exceeds + /// [`MAX_CONFIGURABLE_ACCOUNT_LIMIT`]. + pub fn set_account_limits( + env: Env, + admin: Address, + account_limits: AccountLimits, + ) -> Result<(), TokenLimitError> { + admin.require_auth(); + limits::set_account_limits(&env, &admin, &account_limits) + } + + /// Tracks one item for an account after enforcing its category cap. + /// + /// The `(account, kind, item_id)` tuple is unique. Replaying the same item + /// cannot consume additional capacity. + /// + /// # Authorization + /// + /// `account` must authorize this invocation. + /// + /// # Errors + /// + /// - [`TokenLimitError::NotInitialized`] if the contract is not initialized. + /// - [`TokenLimitError::ItemAlreadyTracked`] if the item is already present. + /// - [`TokenLimitError::BetLimitExceeded`] if the bet cap is reached. + /// - [`TokenLimitError::PositionLimitExceeded`] if the position cap is reached. + /// - [`TokenLimitError::SubscriptionLimitExceeded`] if the subscription cap is reached. + /// - [`TokenLimitError::Overflow`] if the category counter cannot be incremented. + pub fn track_account_item( + env: Env, + account: Address, + kind: AccountStateKind, + item_id: BytesN<32>, + ) -> Result { + account.require_auth(); + limits::track_account_item(&env, &account, &kind, &item_id) + } + + /// Removes one tracked item and releases its occupied capacity. + /// + /// Capacity is released only when the exact stored item exists, preventing + /// callers from decrementing counters with fabricated identifiers. + /// + /// # Authorization + /// + /// `account` must authorize this invocation. + /// + /// # Errors + /// + /// - [`TokenLimitError::NotInitialized`] if the contract is not initialized. + /// - [`TokenLimitError::ItemNotFound`] if the exact item is not tracked. + /// - [`TokenLimitError::Underflow`] if the membership length cannot be decremented. + pub fn untrack_account_item( + env: Env, + account: Address, + kind: AccountStateKind, + item_id: BytesN<32>, + ) -> Result { + account.require_auth(); + limits::untrack_account_item(&env, &account, &kind, &item_id) + } + + /// Returns the configured global per-account limits. + /// + /// # Errors + /// + /// Returns [`TokenLimitError::NotInitialized`] when no limits are configured. + pub fn get_account_limits(env: Env) -> Result { + limits::get_account_limits(&env) + } + + /// Returns the number of tracked items in each category for `account`. + pub fn get_account_usage(env: Env, account: Address) -> AccountUsage { + limits::get_account_usage(&env, &account) + } + + /// Returns the remaining capacity in each category for `account`. + /// + /// If an administrator lowered a cap below current usage, the corresponding + /// remaining value is clamped to zero. + /// + /// # Errors + /// + /// Returns [`TokenLimitError::NotInitialized`] when no limits are configured. + pub fn get_remaining_capacity( + env: Env, + account: Address, + ) -> Result { + limits::get_remaining_capacity(&env, &account) + } + + /// Returns whether the exact account item is currently tracked. + pub fn is_account_item_tracked( + env: Env, + account: Address, + kind: AccountStateKind, + item_id: BytesN<32>, + ) -> bool { + limits::is_account_item_tracked(&env, &account, &kind, &item_id) + } + + /// Returns the initialized administrator. + /// + /// # Errors + /// + /// Returns [`TokenLimitError::NotInitialized`] before initialization. + pub fn get_admin(env: Env) -> Result { + limits::get_admin(&env) + } +} diff --git a/contracts/tokens/src/limits.rs b/contracts/tokens/src/limits.rs new file mode 100644 index 00000000..ef9d2021 --- /dev/null +++ b/contracts/tokens/src/limits.rs @@ -0,0 +1,365 @@ +//! Per-account state limits for token-adjacent records. +//! +//! This module bounds the number of bets, positions, and subscriptions that a +//! single account can keep in contract storage. Counts are derived directly +//! from authenticated, bounded membership maps, so a caller cannot bypass a +//! cap by replaying an add or fabricating a removal. + +use soroban_sdk::{contracterror, contracttype, Address, BytesN, Env, Map}; + +/// Maximum value accepted for any configurable per-account category cap. +/// +/// This hard ceiling prevents an administrator mistake from turning a bounded +/// category into effectively unbounded per-account storage while keeping each +/// encoded membership map at a predictable size. +pub const MAX_CONFIGURABLE_ACCOUNT_LIMIT: u32 = 256; + +const STATE_TTL_THRESHOLD: u32 = 100_000; +const STATE_TTL_EXTEND_TO: u32 = 6_307_200; + +/// Categories of token-adjacent state that are capped per account. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AccountStateKind { + /// A bet identifier owned by the account. + Bet, + /// An open or historical position identifier owned by the account. + Position, + /// A subscription identifier owned by the account. + Subscription, +} + +/// Global caps applied independently to each account. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AccountLimits { + /// Maximum number of tracked bet identifiers per account. + pub bets: u32, + /// Maximum number of tracked position identifiers per account. + pub positions: u32, + /// Maximum number of tracked subscription identifiers per account. + pub subscriptions: u32, +} + +/// Current per-category storage usage for one account. +#[contracttype] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct AccountUsage { + /// Number of tracked bet identifiers. + pub bets: u32, + /// Number of tracked position identifiers. + pub positions: u32, + /// Number of tracked subscription identifiers. + pub subscriptions: u32, +} + +/// Stable semantic errors returned by token account-limit operations. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TokenLimitError { + /// The contract has already been initialized. + AlreadyInitialized = 1, + /// The contract has not been initialized. + NotInitialized = 2, + /// The authenticated address is not the configured administrator. + Unauthorized = 3, + /// A configured category cap exceeds the hard maximum. + InvalidLimit = 4, + /// The exact `(account, kind, item_id)` tuple is already tracked. + ItemAlreadyTracked = 5, + /// The exact `(account, kind, item_id)` tuple is not tracked. + ItemNotFound = 6, + /// The account has reached its configured bet cap. + BetLimitExceeded = 7, + /// The account has reached its configured position cap. + PositionLimitExceeded = 8, + /// The account has reached its configured subscription cap. + SubscriptionLimitExceeded = 9, + /// A checked counter increment overflowed. + Overflow = 10, + /// A checked membership-length decrement underflowed. + Underflow = 11, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +enum DataKey { + Initialized, + Admin, + AccountLimits, + AccountItems(Address, AccountStateKind), +} + +pub(crate) fn initialize( + env: &Env, + admin: &Address, + account_limits: &AccountLimits, +) -> Result<(), TokenLimitError> { + if env.storage().instance().has(&DataKey::Initialized) { + return Err(TokenLimitError::AlreadyInitialized); + } + + validate_limits(account_limits)?; + + env.storage().instance().set(&DataKey::Admin, admin); + env.storage() + .instance() + .set(&DataKey::AccountLimits, account_limits); + env.storage().instance().set(&DataKey::Initialized, &true); + + Ok(()) +} + +pub(crate) fn set_account_limits( + env: &Env, + admin: &Address, + account_limits: &AccountLimits, +) -> Result<(), TokenLimitError> { + require_initialized(env)?; + require_admin(env, admin)?; + validate_limits(account_limits)?; + + env.storage() + .instance() + .set(&DataKey::AccountLimits, account_limits); + + Ok(()) +} + +pub(crate) fn track_account_item( + env: &Env, + account: &Address, + kind: &AccountStateKind, + item_id: &BytesN<32>, +) -> Result { + require_initialized(env)?; + + let mut items = get_account_items(env, account, kind); + if items.contains_key(item_id.clone()) { + return Err(TokenLimitError::ItemAlreadyTracked); + } + + let account_limits = get_account_limits(env)?; + let next_count = increment_count(items.len())?; + enforce_limit(kind, next_count, limit_for_kind(&account_limits, kind))?; + + items.set(item_id.clone(), true); + set_account_items(env, account, kind, &items); + + Ok(get_account_usage(env, account)) +} + +pub(crate) fn untrack_account_item( + env: &Env, + account: &Address, + kind: &AccountStateKind, + item_id: &BytesN<32>, +) -> Result { + require_initialized(env)?; + + let mut items = get_account_items(env, account, kind); + if !items.contains_key(item_id.clone()) { + return Err(TokenLimitError::ItemNotFound); + } + + decrement_count(items.len())?; + items.remove(item_id.clone()); + + let items_key = account_items_key(account, kind); + if items.is_empty() { + env.storage().persistent().remove(&items_key); + } else { + set_account_items(env, account, kind, &items); + } + + Ok(get_account_usage(env, account)) +} + +pub(crate) fn get_account_limits(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::AccountLimits) + .ok_or(TokenLimitError::NotInitialized) +} + +pub(crate) fn get_account_usage(env: &Env, account: &Address) -> AccountUsage { + AccountUsage { + bets: get_account_items(env, account, &AccountStateKind::Bet).len(), + positions: get_account_items(env, account, &AccountStateKind::Position).len(), + subscriptions: get_account_items(env, account, &AccountStateKind::Subscription).len(), + } +} + +pub(crate) fn get_remaining_capacity( + env: &Env, + account: &Address, +) -> Result { + let account_limits = get_account_limits(env)?; + let usage = get_account_usage(env, account); + + Ok(AccountLimits { + bets: account_limits.bets.saturating_sub(usage.bets), + positions: account_limits.positions.saturating_sub(usage.positions), + subscriptions: account_limits + .subscriptions + .saturating_sub(usage.subscriptions), + }) +} + +pub(crate) fn is_account_item_tracked( + env: &Env, + account: &Address, + kind: &AccountStateKind, + item_id: &BytesN<32>, +) -> bool { + get_account_items(env, account, kind).contains_key(item_id.clone()) +} + +pub(crate) fn get_admin(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(TokenLimitError::NotInitialized) +} + +fn require_initialized(env: &Env) -> Result<(), TokenLimitError> { + if env + .storage() + .instance() + .get::(&DataKey::Initialized) + .unwrap_or(false) + { + Ok(()) + } else { + Err(TokenLimitError::NotInitialized) + } +} + +fn require_admin(env: &Env, admin: &Address) -> Result<(), TokenLimitError> { + let stored_admin = get_admin(env)?; + if stored_admin == *admin { + Ok(()) + } else { + Err(TokenLimitError::Unauthorized) + } +} + +fn validate_limits(account_limits: &AccountLimits) -> Result<(), TokenLimitError> { + if account_limits.bets > MAX_CONFIGURABLE_ACCOUNT_LIMIT + || account_limits.positions > MAX_CONFIGURABLE_ACCOUNT_LIMIT + || account_limits.subscriptions > MAX_CONFIGURABLE_ACCOUNT_LIMIT + { + return Err(TokenLimitError::InvalidLimit); + } + + Ok(()) +} + +fn account_items_key(account: &Address, kind: &AccountStateKind) -> DataKey { + DataKey::AccountItems(account.clone(), *kind) +} + +fn get_account_items( + env: &Env, + account: &Address, + kind: &AccountStateKind, +) -> Map, bool> { + env.storage() + .persistent() + .get(&account_items_key(account, kind)) + .unwrap_or_else(|| Map::new(env)) +} + +fn set_account_items( + env: &Env, + account: &Address, + kind: &AccountStateKind, + items: &Map, bool>, +) { + let key = account_items_key(account, kind); + env.storage().persistent().set(&key, items); + bump_persistent_ttl(env, &key); +} + +fn limit_for_kind(account_limits: &AccountLimits, kind: &AccountStateKind) -> u32 { + match kind { + AccountStateKind::Bet => account_limits.bets, + AccountStateKind::Position => account_limits.positions, + AccountStateKind::Subscription => account_limits.subscriptions, + } +} + +fn enforce_limit( + kind: &AccountStateKind, + next_count: u32, + limit: u32, +) -> Result<(), TokenLimitError> { + if next_count <= limit { + return Ok(()); + } + + match kind { + AccountStateKind::Bet => Err(TokenLimitError::BetLimitExceeded), + AccountStateKind::Position => Err(TokenLimitError::PositionLimitExceeded), + AccountStateKind::Subscription => Err(TokenLimitError::SubscriptionLimitExceeded), + } +} + +fn increment_count(current: u32) -> Result { + current.checked_add(1).ok_or(TokenLimitError::Overflow) +} + +fn decrement_count(current: u32) -> Result { + current.checked_sub(1).ok_or(TokenLimitError::Underflow) +} + +fn bump_persistent_ttl(env: &Env, key: &DataKey) { + let extend_to = STATE_TTL_EXTEND_TO.min(env.storage().max_ttl()); + let threshold = STATE_TTL_THRESHOLD.min(extend_to); + env.storage() + .persistent() + .extend_ttl(key, threshold, extend_to); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn checked_counter_math_reports_overflow_and_underflow() { + assert_eq!(increment_count(u32::MAX), Err(TokenLimitError::Overflow)); + assert_eq!(decrement_count(0), Err(TokenLimitError::Underflow)); + } + + #[test] + fn every_kind_maps_to_its_semantic_limit_error() { + assert_eq!( + enforce_limit(&AccountStateKind::Bet, 2, 1), + Err(TokenLimitError::BetLimitExceeded) + ); + assert_eq!( + enforce_limit(&AccountStateKind::Position, 2, 1), + Err(TokenLimitError::PositionLimitExceeded) + ); + assert_eq!( + enforce_limit(&AccountStateKind::Subscription, 2, 1), + Err(TokenLimitError::SubscriptionLimitExceeded) + ); + } + + #[test] + fn error_discriminants_are_stable() { + assert_eq!(TokenLimitError::AlreadyInitialized as u32, 1); + assert_eq!(TokenLimitError::NotInitialized as u32, 2); + assert_eq!(TokenLimitError::Unauthorized as u32, 3); + assert_eq!(TokenLimitError::InvalidLimit as u32, 4); + assert_eq!(TokenLimitError::ItemAlreadyTracked as u32, 5); + assert_eq!(TokenLimitError::ItemNotFound as u32, 6); + assert_eq!(TokenLimitError::BetLimitExceeded as u32, 7); + assert_eq!(TokenLimitError::PositionLimitExceeded as u32, 8); + assert_eq!(TokenLimitError::SubscriptionLimitExceeded as u32, 9); + assert_eq!(TokenLimitError::Overflow as u32, 10); + assert_eq!(TokenLimitError::Underflow as u32, 11); + } +} diff --git a/contracts/tokens/tests/limits.rs b/contracts/tokens/tests/limits.rs new file mode 100644 index 00000000..d11468c6 --- /dev/null +++ b/contracts/tokens/tests/limits.rs @@ -0,0 +1,376 @@ +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + Address, BytesN, Env, IntoVal, +}; +use tokens::{ + AccountLimits, AccountStateKind, AccountUsage, TokenLimitError, TokensContract, + TokensContractClient, MAX_CONFIGURABLE_ACCOUNT_LIMIT, +}; + +fn limits(bets: u32, positions: u32, subscriptions: u32) -> AccountLimits { + AccountLimits { + bets, + positions, + subscriptions, + } +} + +fn item_id(env: &Env, byte: u8) -> BytesN<32> { + BytesN::from_array(env, &[byte; 32]) +} + +fn indexed_item_id(env: &Env, index: u32) -> BytesN<32> { + let mut bytes = [0u8; 32]; + bytes[..4].copy_from_slice(&index.to_be_bytes()); + BytesN::from_array(env, &bytes) +} + +fn setup<'a>(env: &'a Env, account_limits: &AccountLimits) -> (TokensContractClient<'a>, Address) { + env.mock_all_auths(); + let admin = Address::generate(env); + let contract_id = env.register(TokensContract, ()); + let client = TokensContractClient::new(env, &contract_id); + client.initialize(&admin, account_limits); + (client, admin) +} + +#[test] +fn initialize_requires_admin_authentication() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = env.register(TokensContract, ()); + let client = TokensContractClient::new(&env, &contract_id); + + assert!(client.try_initialize(&admin, &limits(1, 1, 1)).is_err()); +} + +#[test] +fn initialize_records_admin_and_limits_and_cannot_repeat() { + let env = Env::default(); + let configured_limits = limits(3, 4, 5); + let (client, admin) = setup(&env, &configured_limits); + + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_account_limits(), configured_limits); + assert_eq!( + client.try_initialize(&admin, &configured_limits), + Err(Ok(TokenLimitError::AlreadyInitialized)) + ); +} + +#[test] +fn initialize_rejects_limits_above_hard_maximum() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(TokensContract, ()); + let client = TokensContractClient::new(&env, &contract_id); + + let invalid_limits = [ + limits(MAX_CONFIGURABLE_ACCOUNT_LIMIT + 1, 1, 1), + limits(1, MAX_CONFIGURABLE_ACCOUNT_LIMIT + 1, 1), + limits(1, 1, MAX_CONFIGURABLE_ACCOUNT_LIMIT + 1), + ]; + + for invalid in invalid_limits { + assert_eq!( + client.try_initialize(&admin, &invalid), + Err(Ok(TokenLimitError::InvalidLimit)) + ); + } +} + +#[test] +fn only_admin_can_update_limits() { + let env = Env::default(); + let (client, admin) = setup(&env, &limits(1, 1, 1)); + let non_admin = Address::generate(&env); + + assert_eq!( + client.try_set_account_limits(&non_admin, &limits(2, 2, 2)), + Err(Ok(TokenLimitError::Unauthorized)) + ); + + client.set_account_limits(&admin, &limits(2, 3, 4)); + assert_eq!(client.get_account_limits(), limits(2, 3, 4)); + + assert_eq!( + client.try_set_account_limits(&admin, &limits(2, 3, MAX_CONFIGURABLE_ACCOUNT_LIMIT + 1)), + Err(Ok(TokenLimitError::InvalidLimit)) + ); + assert_eq!(client.get_account_limits(), limits(2, 3, 4)); +} + +#[test] +fn uninitialized_views_and_mutations_return_stable_defaults_or_errors() { + let env = Env::default(); + env.mock_all_auths(); + let account = Address::generate(&env); + let contract_id = env.register(TokensContract, ()); + let client = TokensContractClient::new(&env, &contract_id); + let id = item_id(&env, 18); + + assert_eq!( + client.try_get_admin(), + Err(Ok(TokenLimitError::NotInitialized)) + ); + assert_eq!( + client.try_get_account_limits(), + Err(Ok(TokenLimitError::NotInitialized)) + ); + assert_eq!( + client.try_get_remaining_capacity(&account), + Err(Ok(TokenLimitError::NotInitialized)) + ); + assert_eq!(client.get_account_usage(&account), AccountUsage::default()); + assert!(!client.is_account_item_tracked(&account, &AccountStateKind::Bet, &id)); + assert_eq!( + client.try_track_account_item(&account, &AccountStateKind::Bet, &id), + Err(Ok(TokenLimitError::NotInitialized)) + ); + assert_eq!( + client.try_untrack_account_item(&account, &AccountStateKind::Bet, &id), + Err(Ok(TokenLimitError::NotInitialized)) + ); +} + +#[test] +fn every_state_category_is_capped_without_partial_mutation() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(1, 1, 1)); + let account = Address::generate(&env); + + let cases = [ + ( + AccountStateKind::Bet, + item_id(&env, 1), + item_id(&env, 2), + TokenLimitError::BetLimitExceeded, + ), + ( + AccountStateKind::Position, + item_id(&env, 3), + item_id(&env, 4), + TokenLimitError::PositionLimitExceeded, + ), + ( + AccountStateKind::Subscription, + item_id(&env, 5), + item_id(&env, 6), + TokenLimitError::SubscriptionLimitExceeded, + ), + ]; + + for (kind, accepted_id, rejected_id, expected_error) in cases { + client.track_account_item(&account, &kind, &accepted_id); + + assert_eq!( + client.try_track_account_item(&account, &kind, &rejected_id), + Err(Ok(expected_error)) + ); + assert!(client.is_account_item_tracked(&account, &kind, &accepted_id)); + assert!(!client.is_account_item_tracked(&account, &kind, &rejected_id)); + } + + assert_eq!( + client.get_account_usage(&account), + AccountUsage { + bets: 1, + positions: 1, + subscriptions: 1, + } + ); +} + +#[test] +fn duplicate_item_does_not_consume_capacity() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(2, 0, 0)); + let account = Address::generate(&env); + let id = item_id(&env, 7); + + client.track_account_item(&account, &AccountStateKind::Bet, &id); + + assert_eq!( + client.try_track_account_item(&account, &AccountStateKind::Bet, &id), + Err(Ok(TokenLimitError::ItemAlreadyTracked)) + ); + assert_eq!(client.get_account_usage(&account).bets, 1); +} + +#[test] +fn usage_and_capacity_are_isolated_per_account() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(1, 1, 1)); + let first = Address::generate(&env); + let second = Address::generate(&env); + let shared_id = item_id(&env, 8); + + client.track_account_item(&first, &AccountStateKind::Bet, &shared_id); + client.track_account_item(&second, &AccountStateKind::Bet, &shared_id); + + assert_eq!(client.get_account_usage(&first).bets, 1); + assert_eq!(client.get_account_usage(&second).bets, 1); + assert_eq!(client.get_remaining_capacity(&first).bets, 0); + assert_eq!(client.get_remaining_capacity(&second).bets, 0); +} + +#[test] +fn untracking_exact_item_releases_capacity() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(1, 0, 0)); + let account = Address::generate(&env); + let first_id = item_id(&env, 9); + let second_id = item_id(&env, 10); + + client.track_account_item(&account, &AccountStateKind::Bet, &first_id); + client.untrack_account_item(&account, &AccountStateKind::Bet, &first_id); + + assert_eq!(client.get_account_usage(&account), AccountUsage::default()); + assert_eq!(client.get_remaining_capacity(&account).bets, 1); + assert!(!client.is_account_item_tracked(&account, &AccountStateKind::Bet, &first_id)); + + client.track_account_item(&account, &AccountStateKind::Bet, &second_id); + assert!(client.is_account_item_tracked(&account, &AccountStateKind::Bet, &second_id)); +} + +#[test] +fn untracking_one_category_preserves_other_usage() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(1, 1, 0)); + let account = Address::generate(&env); + let bet_id = item_id(&env, 19); + let position_id = item_id(&env, 20); + + client.track_account_item(&account, &AccountStateKind::Bet, &bet_id); + client.track_account_item(&account, &AccountStateKind::Position, &position_id); + client.untrack_account_item(&account, &AccountStateKind::Bet, &bet_id); + + assert_eq!( + client.get_account_usage(&account), + AccountUsage { + bets: 0, + positions: 1, + subscriptions: 0, + } + ); + assert!(client.is_account_item_tracked(&account, &AccountStateKind::Position, &position_id)); +} + +#[test] +fn fabricated_untrack_is_rejected_without_changing_usage() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(2, 0, 0)); + let account = Address::generate(&env); + let stored_id = item_id(&env, 11); + let fabricated_id = item_id(&env, 12); + + client.track_account_item(&account, &AccountStateKind::Bet, &stored_id); + + assert_eq!( + client.try_untrack_account_item(&account, &AccountStateKind::Bet, &fabricated_id), + Err(Ok(TokenLimitError::ItemNotFound)) + ); + assert_eq!(client.get_account_usage(&account).bets, 1); + assert!(client.is_account_item_tracked(&account, &AccountStateKind::Bet, &stored_id)); +} + +#[test] +fn zero_limit_disables_new_items_for_that_category() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(0, 1, 1)); + let account = Address::generate(&env); + let id = item_id(&env, 13); + + assert_eq!( + client.try_track_account_item(&account, &AccountStateKind::Bet, &id), + Err(Ok(TokenLimitError::BetLimitExceeded)) + ); + assert_eq!(client.get_account_usage(&account), AccountUsage::default()); +} + +#[test] +fn hard_maximum_accepts_exact_boundary_and_rejects_next_item() { + let env = Env::default(); + let (client, _) = setup(&env, &limits(MAX_CONFIGURABLE_ACCOUNT_LIMIT, 0, 0)); + let account = Address::generate(&env); + + for index in 0..MAX_CONFIGURABLE_ACCOUNT_LIMIT { + client.track_account_item( + &account, + &AccountStateKind::Bet, + &indexed_item_id(&env, index), + ); + } + + assert_eq!( + client.get_account_usage(&account).bets, + MAX_CONFIGURABLE_ACCOUNT_LIMIT + ); + assert_eq!( + client.try_track_account_item( + &account, + &AccountStateKind::Bet, + &indexed_item_id(&env, MAX_CONFIGURABLE_ACCOUNT_LIMIT), + ), + Err(Ok(TokenLimitError::BetLimitExceeded)) + ); +} + +#[test] +fn lowering_limit_below_usage_retains_state_and_blocks_growth() { + let env = Env::default(); + let (client, admin) = setup(&env, &limits(2, 0, 0)); + let account = Address::generate(&env); + let first_id = item_id(&env, 14); + let second_id = item_id(&env, 15); + let third_id = item_id(&env, 16); + + client.track_account_item(&account, &AccountStateKind::Bet, &first_id); + client.track_account_item(&account, &AccountStateKind::Bet, &second_id); + client.set_account_limits(&admin, &limits(1, 0, 0)); + + assert_eq!(client.get_account_usage(&account).bets, 2); + assert_eq!(client.get_remaining_capacity(&account).bets, 0); + assert_eq!( + client.try_track_account_item(&account, &AccountStateKind::Bet, &third_id), + Err(Ok(TokenLimitError::BetLimitExceeded)) + ); +} + +#[test] +fn tracking_captures_the_account_as_required_signer() { + let env = Env::default(); + let admin = Address::generate(&env); + let account = Address::generate(&env); + let contract_id = env.register(TokensContract, ()); + let client = TokensContractClient::new(&env, &contract_id); + + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: (&admin, limits(1, 1, 1)).into_val(&env), + sub_invokes: &[], + }, + }]); + client.initialize(&admin, &limits(1, 1, 1)); + + env.mock_auths(&[MockAuth { + address: &account, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "track_account_item", + args: (&account, AccountStateKind::Bet, item_id(&env, 17)).into_val(&env), + sub_invokes: &[], + }, + }]); + client.track_account_item(&account, &AccountStateKind::Bet, &item_id(&env, 17)); + + let auths = env.auths(); + assert_eq!(auths.len(), 1); + assert_eq!(auths[0].0, account); +}