diff --git a/.gitignore b/.gitignore index f44c231..14ab211 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ save.json save.json.tmp *.roto save.test-*.json +test_snapshots/ diff --git a/contracts/runa_item_token/Cargo.toml b/contracts/runa_item_token/Cargo.toml new file mode 100644 index 0000000..cd8e90f --- /dev/null +++ b/contracts/runa_item_token/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "runa_item_token" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = "=26.1.1" + +[dev-dependencies] +soroban-sdk = { version = "=26.1.1", features = ["testutils"] } diff --git a/contracts/runa_item_token/src/errors.rs b/contracts/runa_item_token/src/errors.rs new file mode 100644 index 0000000..d756302 --- /dev/null +++ b/contracts/runa_item_token/src/errors.rs @@ -0,0 +1,14 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ItemError { + AlreadyInitialized = 1, + NotInitialized = 2, + ItemNotFound = 3, + LevelRequirementNotMet = 4, + InsufficientGold = 5, + InvalidAmount = 6, + InsufficientBalance = 7, +} diff --git a/contracts/runa_item_token/src/lib.rs b/contracts/runa_item_token/src/lib.rs new file mode 100644 index 0000000..804a7aa --- /dev/null +++ b/contracts/runa_item_token/src/lib.rs @@ -0,0 +1,281 @@ +#![no_std] + +pub mod errors; +pub mod types; + +#[cfg(test)] +pub mod test; + +use crate::errors::ItemError; +use crate::types::{InventorySummary, ItemMetadata}; +use soroban_sdk::{ + contract, contractimpl, symbol_short, token, Address, Env, Symbol, +}; +use types::ItemDataKey; + +const INSTANCE_LIFETIME_THRESHOLD: u32 = 100_000; +const INSTANCE_BUMP_AMOUNT: u32 = 200_000; +const PERSISTENT_LIFETIME_THRESHOLD: u32 = 100_000; +const PERSISTENT_BUMP_AMOUNT: u32 = 200_000; + +fn extend_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); +} + +fn extend_persistent_ttl(env: &Env, key: &ItemDataKey) { + env.storage() + .persistent() + .extend_ttl(key, PERSISTENT_LIFETIME_THRESHOLD, PERSISTENT_BUMP_AMOUNT); +} + +#[contract] +pub struct RunaItemTokenContract; + +#[contractimpl] +impl RunaItemTokenContract { + /// Initialize item token contract with admin and authorized game contract + pub fn initialize( + env: Env, + admin: Address, + game_contract: Address, + ) -> Result<(), ItemError> { + admin.require_auth(); + if env.storage().instance().has(&ItemDataKey::Admin) { + return Err(ItemError::AlreadyInitialized); + } + + env.storage().instance().set(&ItemDataKey::Admin, &admin); + env.storage() + .instance() + .set(&ItemDataKey::AuthorizedGameContract, &game_contract); + + extend_instance_ttl(&env); + Ok(()) + } + + /// Register or update item metadata (admin only) + pub fn register_item(env: Env, metadata: ItemMetadata) -> Result<(), ItemError> { + let admin: Address = env + .storage() + .instance() + .get(&ItemDataKey::Admin) + .ok_or(ItemError::NotInitialized)?; + + admin.require_auth(); + + let key = ItemDataKey::Item(metadata.id.clone()); + env.storage().persistent().set(&key, &metadata); + extend_persistent_ttl(&env, &key); + + env.events().publish( + (symbol_short!("item"), symbol_short!("reg")), + metadata.id, + ); + + Ok(()) + } + + /// Query item metadata by symbol ID + pub fn get_item(env: Env, item_id: Symbol) -> Result { + let key = ItemDataKey::Item(item_id); + let metadata = env + .storage() + .persistent() + .get(&key) + .ok_or(ItemError::ItemNotFound)?; + extend_persistent_ttl(&env, &key); + Ok(metadata) + } + + /// Mint item upon verified game purchase (Issue #2 level & gold gating enforced) + pub fn mint_item( + env: Env, + to: Address, + item_id: Symbol, + gold_paid: i128, + player_level: u32, + ) -> Result<(), ItemError> { + if !env.storage().instance().has(&ItemDataKey::Admin) { + return Err(ItemError::NotInitialized); + } + + let game_contract: Address = env + .storage() + .instance() + .get(&ItemDataKey::AuthorizedGameContract) + .ok_or(ItemError::NotInitialized)?; + + game_contract.require_auth(); + to.require_auth(); + + // Fetch item metadata + let key = ItemDataKey::Item(item_id.clone()); + let metadata: ItemMetadata = env + .storage() + .persistent() + .get(&key) + .ok_or(ItemError::ItemNotFound)?; + + // Issue #2 Gate: Check level requirement + if player_level < metadata.min_level { + return Err(ItemError::LevelRequirementNotMet); + } + + // Check gold price paid + if gold_paid < metadata.base_price { + return Err(ItemError::InsufficientGold); + } + + // Transfer gold price into the shop before crediting balance + let token_client = token::Client::new(&env, &metadata.sac_token); + token_client.transfer(&to, &env.current_contract_address(), &gold_paid); + + // Increment player balance + let balance_key = ItemDataKey::PlayerBalance(to.clone(), item_id.clone()); + let current_balance: u32 = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0); + let new_balance = current_balance.checked_add(1).ok_or(ItemError::InvalidAmount)?; + + env.storage().persistent().set(&balance_key, &new_balance); + extend_persistent_ttl(&env, &balance_key); + + env.events().publish( + (symbol_short!("item"), symbol_short!("minted")), + (to, item_id, gold_paid), + ); + + Ok(()) + } + + /// Burn / sell item back to shop for 50% gold refund + pub fn burn_item(env: Env, from: Address, item_id: Symbol) -> Result { + if !env.storage().instance().has(&ItemDataKey::Admin) { + return Err(ItemError::NotInitialized); + } + + from.require_auth(); + + // Fetch item metadata + let key = ItemDataKey::Item(item_id.clone()); + let metadata: ItemMetadata = env + .storage() + .persistent() + .get(&key) + .ok_or(ItemError::ItemNotFound)?; + + let balance_key = ItemDataKey::PlayerBalance(from.clone(), item_id.clone()); + let current_balance: u32 = env + .storage() + .persistent() + .get(&balance_key) + .unwrap_or(0); + + if current_balance == 0 { + return Err(ItemError::InsufficientBalance); + } + + let new_balance = current_balance - 1; + env.storage().persistent().set(&balance_key, &new_balance); + extend_persistent_ttl(&env, &balance_key); + + // Calculate 50% refund payout + let payout = metadata.base_price / 2; + + let token_client = token::Client::new(&env, &metadata.sac_token); + token_client.transfer(&env.current_contract_address(), &from, &payout); + + env.events().publish( + (symbol_short!("item"), symbol_short!("burned")), + (from, item_id, payout), + ); + + Ok(payout) + } + + /// Query player balance for a specific item ID + pub fn get_player_item_balance(env: Env, player: Address, item_id: Symbol) -> u32 { + let balance_key = ItemDataKey::PlayerBalance(player, item_id); + env.storage().persistent().get(&balance_key).unwrap_or(0) + } + + /// Query full player inventory summary across all 4 standard equipment assets + pub fn get_player_inventory(env: Env, player: Address) -> Result { + let sword_sym = Symbol::new(&env, "sword"); + let crossbow_sym = Symbol::new(&env, "crossbow"); + let shield_sym = Symbol::new(&env, "shield"); + let boots_sym = Symbol::new(&env, "boots"); + + let sword_count = Self::get_player_item_balance(env.clone(), player.clone(), sword_sym); + let crossbow_count = + Self::get_player_item_balance(env.clone(), player.clone(), crossbow_sym); + let shield_count = Self::get_player_item_balance(env.clone(), player.clone(), shield_sym); + let boots_count = Self::get_player_item_balance(env.clone(), player, boots_sym); + + Ok(InventorySummary { + sword_count, + crossbow_count, + shield_count, + boots_count, + }) + } + + /// Transfer item from one player account to another + pub fn transfer_item( + env: Env, + from: Address, + to: Address, + item_id: Symbol, + ) -> Result<(), ItemError> { + from.require_auth(); + + // Verify item exists + let item_key = ItemDataKey::Item(item_id.clone()); + if !env.storage().persistent().has(&item_key) { + return Err(ItemError::ItemNotFound); + } + + let from_balance_key = ItemDataKey::PlayerBalance(from.clone(), item_id.clone()); + let from_balance: u32 = env + .storage() + .persistent() + .get(&from_balance_key) + .unwrap_or(0); + + if from_balance == 0 { + return Err(ItemError::InsufficientBalance); + } + + if from == to { + return Ok(()); + } + + let to_balance_key = ItemDataKey::PlayerBalance(to.clone(), item_id.clone()); + let to_balance: u32 = env + .storage() + .persistent() + .get(&to_balance_key) + .unwrap_or(0); + + env.storage() + .persistent() + .set(&from_balance_key, &(from_balance - 1)); + env.storage() + .persistent() + .set(&to_balance_key, &(to_balance + 1)); + + extend_persistent_ttl(&env, &from_balance_key); + extend_persistent_ttl(&env, &to_balance_key); + + env.events().publish( + (symbol_short!("item"), symbol_short!("transfer")), + (from, to, item_id), + ); + + Ok(()) + } +} diff --git a/contracts/runa_item_token/src/test.rs b/contracts/runa_item_token/src/test.rs new file mode 100644 index 0000000..978b5a2 --- /dev/null +++ b/contracts/runa_item_token/src/test.rs @@ -0,0 +1,294 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; + +struct TestSetup { + env: Env, + admin: Address, + game_contract: Address, + player: Address, + recipient: Address, + contract_id: Address, + token_client: token::Client<'static>, + client: RunaItemTokenContractClient<'static>, +} + +fn setup_test() -> TestSetup { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let game_contract = Address::generate(&env); + let player = Address::generate(&env); + let recipient = Address::generate(&env); + + let contract_id = env.register(RunaItemTokenContract, ()); + let client = RunaItemTokenContractClient::new(&env, &contract_id); + + client.initialize(&admin, &game_contract); + + // Register token SAC and mint test gold + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin); + let token_admin_client = token::StellarAssetClient::new(&env, &token_contract.address()); + let token_client = token::Client::new(&env, &token_contract.address()); + token_admin_client.mint(&player, &1_000_000_000); + token_admin_client.mint(&recipient, &1_000_000_000); + + let dummy_sac = token_contract.address(); + + // sword: base_price = 25, min_level = 1 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "sword"), + hand: Symbol::new(&env, "left"), + kind: Symbol::new(&env, "weapon"), + base_price: 25, + min_level: 1, + sac_token: dummy_sac.clone(), + }); + + // crossbow: base_price = 60, min_level = 2 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "crossbow"), + hand: Symbol::new(&env, "left"), + kind: Symbol::new(&env, "weapon"), + base_price: 60, + min_level: 2, + sac_token: dummy_sac.clone(), + }); + + // shield: base_price = 30, min_level = 1 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "shield"), + hand: Symbol::new(&env, "right"), + kind: Symbol::new(&env, "armor"), + base_price: 30, + min_level: 1, + sac_token: dummy_sac.clone(), + }); + + // boots: base_price = 45, min_level = 2 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "boots"), + hand: Symbol::new(&env, "right"), + kind: Symbol::new(&env, "armor"), + base_price: 45, + min_level: 2, + sac_token: dummy_sac, + }); + + TestSetup { + env, + admin, + game_contract, + player, + recipient, + contract_id, + token_client, + client, + } +} + +#[test] +fn test_initialize_and_double_init() { + let setup = setup_test(); + let res = setup + .client + .try_initialize(&setup.admin, &setup.game_contract); + assert!(res.is_err()); +} + +#[test] +fn test_get_item_metadata() { + let setup = setup_test(); + let sword = setup.client.get_item(&Symbol::new(&setup.env, "sword")); + assert_eq!(sword.base_price, 25); + assert_eq!(sword.min_level, 1); + + let crossbow = setup.client.get_item(&Symbol::new(&setup.env, "crossbow")); + assert_eq!(crossbow.base_price, 60); + assert_eq!(crossbow.min_level, 2); +} + +#[test] +fn test_mint_item_happy_path() { + let setup = setup_test(); + let sword_sym = Symbol::new(&setup.env, "sword"); + + assert_eq!(setup.token_client.balance(&setup.player), 1_000_000_000); + assert_eq!(setup.token_client.balance(&setup.contract_id), 0); + + setup + .client + .mint_item(&setup.player, &sword_sym, &25, &1); + + assert_eq!(setup.token_client.balance(&setup.player), 999_999_975); + assert_eq!(setup.token_client.balance(&setup.contract_id), 25); + + let balance = setup + .client + .get_player_item_balance(&setup.player, &sword_sym); + assert_eq!(balance, 1); + + let inventory = setup.client.get_player_inventory(&setup.player); + assert_eq!(inventory.sword_count, 1); + assert_eq!(inventory.crossbow_count, 0); + assert_eq!(inventory.shield_count, 0); + assert_eq!(inventory.boots_count, 0); +} + +#[test] +fn test_mint_item_level_gate_rejection_issue_2_fix() { + let setup = setup_test(); + let crossbow_sym = Symbol::new(&setup.env, "crossbow"); + + // Level 1 player attempts to purchase level 2 crossbow with full gold -> MUST FAIL + let res = setup + .client + .try_mint_item(&setup.player, &crossbow_sym, &60, &1); + assert!(res.is_err()); + + // Boots require level 2 -> level 1 player fails + let boots_sym = Symbol::new(&setup.env, "boots"); + let boots_res = setup + .client + .try_mint_item(&setup.player, &boots_sym, &45, &1); + assert!(boots_res.is_err()); + + // Once player reaches level 2 -> succeeds + setup + .client + .mint_item(&setup.player, &crossbow_sym, &60, &2); + let balance = setup + .client + .get_player_item_balance(&setup.player, &crossbow_sym); + assert_eq!(balance, 1); +} + +#[test] +fn test_mint_item_insufficient_gold_fails() { + let setup = setup_test(); + let shield_sym = Symbol::new(&setup.env, "shield"); + + // Shield costs 30g, paying only 20g fails + let res = setup + .client + .try_mint_item(&setup.player, &shield_sym, &20, &1); + assert!(res.is_err()); +} + +#[test] +fn test_burn_item_and_50_percent_refund() { + let setup = setup_test(); + let sword_sym = Symbol::new(&setup.env, "sword"); + let crossbow_sym = Symbol::new(&setup.env, "crossbow"); + + // Mint sword (25g) and crossbow (60g) + setup + .client + .mint_item(&setup.player, &sword_sym, &25, &1); + setup + .client + .mint_item(&setup.player, &crossbow_sym, &60, &2); + + assert_eq!(setup.token_client.balance(&setup.contract_id), 85); + assert_eq!(setup.token_client.balance(&setup.player), 999_999_915); + + // Burn sword -> 50% refund = 25 / 2 = 12g + let sword_refund = setup.client.burn_item(&setup.player, &sword_sym); + assert_eq!(sword_refund, 12); + assert_eq!(setup.token_client.balance(&setup.contract_id), 73); + assert_eq!(setup.token_client.balance(&setup.player), 999_999_927); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player, &sword_sym), + 0 + ); + + // Burn crossbow -> 50% refund = 60 / 2 = 30g + let crossbow_refund = setup.client.burn_item(&setup.player, &crossbow_sym); + assert_eq!(crossbow_refund, 30); + assert_eq!(setup.token_client.balance(&setup.contract_id), 43); + assert_eq!(setup.token_client.balance(&setup.player), 999_999_957); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player, &crossbow_sym), + 0 + ); +} + +#[test] +fn test_burn_item_insufficient_balance_fails() { + let setup = setup_test(); + let boots_sym = Symbol::new(&setup.env, "boots"); + + let res = setup.client.try_burn_item(&setup.player, &boots_sym); + assert!(res.is_err()); +} + +#[test] +fn test_inventory_summary_all_items() { + let setup = setup_test(); + let sword_sym = Symbol::new(&setup.env, "sword"); + let crossbow_sym = Symbol::new(&setup.env, "crossbow"); + let shield_sym = Symbol::new(&setup.env, "shield"); + let boots_sym = Symbol::new(&setup.env, "boots"); + + setup + .client + .mint_item(&setup.player, &sword_sym, &25, &1); + setup + .client + .mint_item(&setup.player, &sword_sym, &25, &1); + setup + .client + .mint_item(&setup.player, &crossbow_sym, &60, &2); + setup + .client + .mint_item(&setup.player, &shield_sym, &30, &1); + setup + .client + .mint_item(&setup.player, &boots_sym, &45, &2); + + let inventory = setup.client.get_player_inventory(&setup.player); + assert_eq!(inventory.sword_count, 2); + assert_eq!(inventory.crossbow_count, 1); + assert_eq!(inventory.shield_count, 1); + assert_eq!(inventory.boots_count, 1); +} + +#[test] +fn test_transfer_item() { + let setup = setup_test(); + let shield_sym = Symbol::new(&setup.env, "shield"); + + setup + .client + .mint_item(&setup.player, &shield_sym, &30, &1); + + setup + .client + .transfer_item(&setup.player, &setup.recipient, &shield_sym); + + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player, &shield_sym), + 0 + ); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.recipient, &shield_sym), + 1 + ); + + // Transferring without balance fails + let fail_res = setup + .client + .try_transfer_item(&setup.player, &setup.recipient, &shield_sym); + assert!(fail_res.is_err()); +} diff --git a/contracts/runa_item_token/src/types.rs b/contracts/runa_item_token/src/types.rs new file mode 100644 index 0000000..b078f03 --- /dev/null +++ b/contracts/runa_item_token/src/types.rs @@ -0,0 +1,30 @@ +use soroban_sdk::{contracttype, Address, Symbol}; + +#[derive(Clone)] +#[contracttype] +pub enum ItemDataKey { + Admin, + AuthorizedGameContract, + Item(Symbol), + PlayerBalance(Address, Symbol), +} + +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct ItemMetadata { + pub id: Symbol, + pub hand: Symbol, + pub kind: Symbol, + pub base_price: i128, + pub min_level: u32, + pub sac_token: Address, +} + +#[derive(Clone, Debug, PartialEq)] +#[contracttype] +pub struct InventorySummary { + pub sword_count: u32, + pub crossbow_count: u32, + pub shield_count: u32, + pub boots_count: u32, +} diff --git a/contracts/runa_item_token/tests/adversarial_item_tests.rs b/contracts/runa_item_token/tests/adversarial_item_tests.rs new file mode 100644 index 0000000..2514d0d --- /dev/null +++ b/contracts/runa_item_token/tests/adversarial_item_tests.rs @@ -0,0 +1,278 @@ +#![cfg(test)] + +use runa_item_token::types::ItemMetadata; +use runa_item_token::{RunaItemTokenContract, RunaItemTokenContractClient}; +use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; + +struct ItemSetup { + env: Env, + _admin: Address, + _game_contract: Address, + player1: Address, + player2: Address, + client: RunaItemTokenContractClient<'static>, +} + +fn setup_items() -> ItemSetup { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let game_contract = Address::generate(&env); + let player1 = Address::generate(&env); + let player2 = Address::generate(&env); + + let contract_id = env.register(RunaItemTokenContract, ()); + let client = RunaItemTokenContractClient::new(&env, &contract_id); + client.initialize(&admin, &game_contract); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin); + let token_admin_client = token::StellarAssetClient::new(&env, &token_contract.address()); + token_admin_client.mint(&player1, &1_000_000_000); + token_admin_client.mint(&player2, &1_000_000_000); + + let dummy_sac = token_contract.address(); + + // sword: base_price = 25, min_level = 1 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "sword"), + hand: Symbol::new(&env, "left"), + kind: Symbol::new(&env, "weapon"), + base_price: 25, + min_level: 1, + sac_token: dummy_sac.clone(), + }); + + // crossbow: base_price = 60, min_level = 2 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "crossbow"), + hand: Symbol::new(&env, "left"), + kind: Symbol::new(&env, "weapon"), + base_price: 60, + min_level: 2, + sac_token: dummy_sac.clone(), + }); + + // shield: base_price = 30, min_level = 1 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "shield"), + hand: Symbol::new(&env, "right"), + kind: Symbol::new(&env, "armor"), + base_price: 30, + min_level: 1, + sac_token: dummy_sac.clone(), + }); + + // boots: base_price = 45, min_level = 2 + client.register_item(&ItemMetadata { + id: Symbol::new(&env, "boots"), + hand: Symbol::new(&env, "right"), + kind: Symbol::new(&env, "armor"), + base_price: 45, + min_level: 2, + sac_token: dummy_sac, + }); + + ItemSetup { + env, + _admin: admin, + _game_contract: game_contract, + player1, + player2, + client, + } +} + +#[test] +fn test_challenge4_all_items_level_gating_adversarial_matrix() { + let setup = setup_items(); + + let items = [ + (Symbol::new(&setup.env, "sword"), 25i128, 1u32), + (Symbol::new(&setup.env, "crossbow"), 60i128, 2u32), + (Symbol::new(&setup.env, "shield"), 30i128, 1u32), + (Symbol::new(&setup.env, "boots"), 45i128, 2u32), + ]; + + for (item_sym, price, min_level) in items.iter() { + // Test levels 0 up to min_level - 1 -> all must fail with LevelRequirementNotMet + for lvl in 0..*min_level { + // Even if paying 10x the gold price, level requirement must block purchase + let res = setup + .client + .try_mint_item(&setup.player1, item_sym, &(price * 10), &lvl); + assert!( + res.is_err(), + "Item {:?} must be rejected for player level {} (min_level {})", + item_sym, + lvl, + min_level + ); + } + + // Test at min_level -> succeeds + let res = setup + .client + .try_mint_item(&setup.player1, item_sym, price, min_level); + assert!( + res.is_ok(), + "Item {:?} should succeed for level {}", + item_sym, + min_level + ); + } +} + +#[test] +fn test_challenge4_gold_price_underpayment_stress() { + let setup = setup_items(); + + let items = [ + (Symbol::new(&setup.env, "sword"), 25i128, 1u32), + (Symbol::new(&setup.env, "crossbow"), 60i128, 2u32), + (Symbol::new(&setup.env, "shield"), 30i128, 1u32), + (Symbol::new(&setup.env, "boots"), 45i128, 2u32), + ]; + + for (item_sym, price, min_level) in items.iter() { + // Underpay by 1 gold + let res1 = setup + .client + .try_mint_item(&setup.player1, item_sym, &(price - 1), min_level); + assert!(res1.is_err(), "Paying price - 1 must fail"); + + // Pay 0 gold + let res2 = setup + .client + .try_mint_item(&setup.player1, item_sym, &0, min_level); + assert!(res2.is_err(), "Paying 0 gold must fail"); + + // Pay negative gold + let res3 = setup + .client + .try_mint_item(&setup.player1, item_sym, &-50, min_level); + assert!(res3.is_err(), "Paying negative gold must fail"); + } +} + +#[test] +fn test_challenge4_burn_refund_exact_arithmetic_and_double_burn() { + let setup = setup_items(); + + let items = [ + (Symbol::new(&setup.env, "sword"), 25i128, 1u32, 12i128), + (Symbol::new(&setup.env, "crossbow"), 60i128, 2u32, 30i128), + (Symbol::new(&setup.env, "shield"), 30i128, 1u32, 15i128), + (Symbol::new(&setup.env, "boots"), 45i128, 2u32, 22i128), + ]; + + for (item_sym, price, min_level, expected_refund) in items.iter() { + // Mint 1 item + setup + .client + .mint_item(&setup.player1, item_sym, price, min_level); + + // Burn item -> verify refund + let refund = setup.client.burn_item(&setup.player1, item_sym); + assert_eq!( + refund, *expected_refund, + "Refund for {:?} must equal {}", + item_sym, expected_refund + ); + + // Attempt second burn when balance is 0 -> MUST FAIL + let fail_res = setup.client.try_burn_item(&setup.player1, item_sym); + assert!( + fail_res.is_err(), + "Second burn of {:?} must fail due to InsufficientBalance", + item_sym + ); + } +} + +#[test] +fn test_challenge4_transfer_drain_and_double_spend() { + let setup = setup_items(); + let sword_sym = Symbol::new(&setup.env, "sword"); + + // Mint 1 sword for player1 + setup + .client + .mint_item(&setup.player1, &sword_sym, &25, &1); + + // Transfer from player1 to player2 + setup + .client + .transfer_item(&setup.player1, &setup.player2, &sword_sym); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player1, &sword_sym), + 0 + ); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player2, &sword_sym), + 1 + ); + + // Player 1 attempts double spend transfer + let res = setup + .client + .try_transfer_item(&setup.player1, &setup.player2, &sword_sym); + assert!(res.is_err(), "Double spend transfer must fail"); + + // Player 1 attempts burn on transferred item + let burn_res = setup.client.try_burn_item(&setup.player1, &sword_sym); + assert!(burn_res.is_err(), "Burning transferred item must fail"); + + // Player 2 transfers to self -> balance stays 1 + setup + .client + .transfer_item(&setup.player2, &setup.player2, &sword_sym); + assert_eq!( + setup + .client + .get_player_item_balance(&setup.player2, &sword_sym), + 1 + ); +} + +#[test] +fn test_challenge4_inventory_summary_invariants() { + let setup = setup_items(); + let sword_sym = Symbol::new(&setup.env, "sword"); + let crossbow_sym = Symbol::new(&setup.env, "crossbow"); + let shield_sym = Symbol::new(&setup.env, "shield"); + let boots_sym = Symbol::new(&setup.env, "boots"); + + // Mint multi items + setup.client.mint_item(&setup.player1, &sword_sym, &25, &1); + setup.client.mint_item(&setup.player1, &sword_sym, &25, &1); + setup.client.mint_item(&setup.player1, &crossbow_sym, &60, &2); + setup.client.mint_item(&setup.player1, &shield_sym, &30, &1); + setup.client.mint_item(&setup.player1, &boots_sym, &45, &2); + + let inv = setup.client.get_player_inventory(&setup.player1); + assert_eq!(inv.sword_count, 2); + assert_eq!(inv.crossbow_count, 1); + assert_eq!(inv.shield_count, 1); + assert_eq!(inv.boots_count, 1); + + // Burn 1 sword + setup.client.burn_item(&setup.player1, &sword_sym); + let inv2 = setup.client.get_player_inventory(&setup.player1); + assert_eq!(inv2.sword_count, 1); + + // Transfer 1 crossbow to player2 + setup + .client + .transfer_item(&setup.player1, &setup.player2, &crossbow_sym); + let inv3 = setup.client.get_player_inventory(&setup.player1); + assert_eq!(inv3.crossbow_count, 0); + + let inv_p2 = setup.client.get_player_inventory(&setup.player2); + assert_eq!(inv_p2.crossbow_count, 1); +}