-
Notifications
You must be signed in to change notification settings - Fork 3
feat(contract): Soroban item token and inventory shop (#15) #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
leocagli
merged 2 commits into
Bitcoindefi:main
from
s6pa1rta3n-lab:feat/runa-item-token
Aug 26, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,3 +9,4 @@ save.json | |
| save.json.tmp | ||
| *.roto | ||
| save.test-*.json | ||
| test_snapshots/ | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ItemMetadata, ItemError> { | ||
| 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<i128, ItemError> { | ||
| 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<InventorySummary, ItemError> { | ||
| 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(()) | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.