diff --git a/.github/workflows/nftopia-backend.yml b/.github/workflows/nftopia-backend.yml index f42e2dab..76f25470 100644 --- a/.github/workflows/nftopia-backend.yml +++ b/.github/workflows/nftopia-backend.yml @@ -110,7 +110,7 @@ jobs: - name: Upload build artifact if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: nftopia-backend-dist path: nftopia-backend/dist/ \ No newline at end of file diff --git a/.github/workflows/nftopia-frontend.yml b/.github/workflows/nftopia-frontend.yml index d97b8ff7..fb39c2ba 100644 --- a/.github/workflows/nftopia-frontend.yml +++ b/.github/workflows/nftopia-frontend.yml @@ -68,7 +68,7 @@ jobs: - name: Upload build artifact if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: nftopia-frontend-build path: nftopia-frontend/.next/ \ No newline at end of file diff --git a/.github/workflows/nftopia-stellar.yml b/.github/workflows/nftopia-stellar.yml index 7daa16cd..b9e027c3 100644 --- a/.github/workflows/nftopia-stellar.yml +++ b/.github/workflows/nftopia-stellar.yml @@ -63,7 +63,7 @@ jobs: - name: Upload contract artifact if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: nftopia-stellar-contract path: nftopia-stellar/target/wasm32-unknown-unknown/release/*.wasm \ No newline at end of file diff --git a/.turbo/cookies/1.cookie b/.turbo/cookies/1.cookie new file mode 100644 index 00000000..e69de29b diff --git a/.turbo/cookies/2.cookie b/.turbo/cookies/2.cookie new file mode 100644 index 00000000..e69de29b diff --git a/.turbo/cookies/3.cookie b/.turbo/cookies/3.cookie new file mode 100644 index 00000000..e69de29b diff --git a/.turbo/cookies/4.cookie b/.turbo/cookies/4.cookie new file mode 100644 index 00000000..e69de29b diff --git a/.turbo/cookies/5.cookie b/.turbo/cookies/5.cookie new file mode 100644 index 00000000..e69de29b diff --git a/.turbo/daemon/964d605253d87a62-turbo.log.2026-01-29 b/.turbo/daemon/964d605253d87a62-turbo.log.2026-01-29 new file mode 100644 index 00000000..2a6fe33d --- /dev/null +++ b/.turbo/daemon/964d605253d87a62-turbo.log.2026-01-29 @@ -0,0 +1,18 @@ +2026-01-29T03:38:10.501310Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/1.cookie"), AnchoredSystemPathBuf(".turbo/cookies/.turbo-cookie")} +2026-01-29T03:38:10.501332Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:38:10.600644Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cache")} +2026-01-29T03:38:10.600654Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:40:36.303858Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/2.cookie")} +2026-01-29T03:40:36.304546Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:40:54.801182Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/3.cookie")} +2026-01-29T03:40:54.801191Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:41:23.000662Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/4.cookie")} +2026-01-29T03:41:23.000672Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:42:21.604995Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".turbo/cookies/5.cookie")} +2026-01-29T03:42:21.605006Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:45:26.500260Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".github/workflows/nftopia-stellar.yml")} +2026-01-29T03:45:26.500293Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:45:36.700367Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".github/workflows/nftopia-frontend.yml")} +2026-01-29T03:45:36.700380Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) +2026-01-29T03:45:41.406663Z WARN turborepo_lib::package_changes_watcher: changed_files: {AnchoredSystemPathBuf(".github/workflows/nftopia-backend.yml")} +2026-01-29T03:45:41.406680Z WARN turborepo_lib::package_changes_watcher: changed_packages: Ok(Some({WorkspacePackage { name: Root, path: AnchoredSystemPathBuf("") }})) diff --git a/nftopia-stellar/contracts/nft_contract/Cargo.toml b/nftopia-stellar/contracts/nft_contract/Cargo.toml index bd94153a..db1bc8e4 100644 --- a/nftopia-stellar/contracts/nft_contract/Cargo.toml +++ b/nftopia-stellar/contracts/nft_contract/Cargo.toml @@ -4,3 +4,11 @@ version = "0.1.0" edition = "2024" [dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[lib] +crate-type = ["cdylib"] +path = "src/lib.rs" diff --git a/nftopia-stellar/contracts/nft_contract/src/access_control.rs b/nftopia-stellar/contracts/nft_contract/src/access_control.rs new file mode 100644 index 00000000..84f7b9a1 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/access_control.rs @@ -0,0 +1,54 @@ +use soroban_sdk::{contracttype, Address, Env}; + +use crate::error::ContractError; +use crate::storage::{get_owner, has_role_entry, set_role_entry}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Role { + Owner, + Admin, + Minter, + Burner, + MetadataUpdater, +} + +pub fn set_role(env: &Env, role: Role, addr: Address, enabled: bool) { + set_role_entry(env, role, &addr, enabled); +} + +pub fn has_role(env: &Env, role: Role, addr: &Address) -> bool { + if role == Role::Owner { + if let Ok(owner) = get_owner(env) { + return &owner == addr; + } + return false; + } + + if let Ok(owner) = get_owner(env) { + if &owner == addr { + return true; + } + } + + has_role_entry(env, role, addr) +} + +pub fn require_role(env: &Env, role: Role) -> Result<(), ContractError> { + let invoker = env.invoker(); + invoker.require_auth(); + if !has_role(env, role, &invoker) { + return Err(ContractError::Unauthorized); + } + Ok(()) +} + +pub fn require_owner(env: &Env) -> Result<(), ContractError> { + let invoker = env.invoker(); + invoker.require_auth(); + let owner = get_owner(env)?; + if invoker != owner { + return Err(ContractError::Unauthorized); + } + Ok(()) +} diff --git a/nftopia-stellar/contracts/nft_contract/src/error.rs b/nftopia-stellar/contracts/nft_contract/src/error.rs new file mode 100644 index 00000000..e82d6def --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/error.rs @@ -0,0 +1,22 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ContractError { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + TokenNotFound = 4, + InvalidBatchLength = 5, + MaxSupplyReached = 6, + MetadataFrozen = 7, + BurnNotConfirmed = 8, + TransfersPaused = 9, + MintingPaused = 10, + ContractPaused = 11, + InvalidRoyaltyPercentage = 12, + RevealNotReady = 13, + InvalidSalePrice = 14, + NotWhitelisted = 15, + ReentrancyDetected = 16, +} diff --git a/nftopia-stellar/contracts/nft_contract/src/events.rs b/nftopia-stellar/contracts/nft_contract/src/events.rs new file mode 100644 index 00000000..2ff12e7e --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/events.rs @@ -0,0 +1,52 @@ +use soroban_sdk::{Address, Env, String, Symbol}; + +use crate::access_control::Role; + +pub fn emit_transfer(env: &Env, from: Address, to: Address, token_id: u64) { + env.events() + .publish((Symbol::new(env, "transfer"),), (from, to, token_id)); +} + +pub fn emit_mint(env: &Env, to: Address, token_id: u64) { + env.events() + .publish((Symbol::new(env, "mint"),), (to, token_id)); +} + +pub fn emit_burn(env: &Env, owner: Address, token_id: u64) { + env.events() + .publish((Symbol::new(env, "burn"),), (owner, token_id)); +} + +pub fn emit_approval(env: &Env, approved: Address, token_id: u64) { + env.events() + .publish((Symbol::new(env, "approval"),), (approved, token_id)); +} + +pub fn emit_approval_for_all(env: &Env, owner: Address, operator: Address, approved: bool) { + env.events() + .publish((Symbol::new(env, "approval_for_all"),), (owner, operator, approved)); +} + +pub fn emit_metadata_update(env: &Env, token_id: u64) { + env.events() + .publish((Symbol::new(env, "metadata_update"),), token_id); +} + +pub fn emit_base_uri_update(env: &Env, uri: String) { + env.events() + .publish((Symbol::new(env, "base_uri_update"),), uri); +} + +pub fn emit_pause(env: &Env, paused: bool) { + env.events().publish((Symbol::new(env, "pause"),), paused); +} + +pub fn emit_role_grant(env: &Env, role: Role, to: Address) { + env.events() + .publish((Symbol::new(env, "role_grant"),), (role, to)); +} + +pub fn emit_role_revoke(env: &Env, role: Role, from: Address) { + env.events() + .publish((Symbol::new(env, "role_revoke"),), (role, from)); +} diff --git a/nftopia-stellar/contracts/nft_contract/src/interface.rs b/nftopia-stellar/contracts/nft_contract/src/interface.rs new file mode 100644 index 00000000..8e48e868 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/interface.rs @@ -0,0 +1,10 @@ +use soroban_sdk::{Env, Symbol}; + +pub fn supports_interface_id(env: &Env, interface_id: Symbol) -> bool { + let nft = Symbol::new(env, "nft"); + let metadata = Symbol::new(env, "metadata"); + let royalty = Symbol::new(env, "royalty"); + let access = Symbol::new(env, "access_control"); + + interface_id == nft || interface_id == metadata || interface_id == royalty || interface_id == access +} diff --git a/nftopia-stellar/contracts/nft_contract/src/lib.rs b/nftopia-stellar/contracts/nft_contract/src/lib.rs index e69de29b..cf03c1af 100644 --- a/nftopia-stellar/contracts/nft_contract/src/lib.rs +++ b/nftopia-stellar/contracts/nft_contract/src/lib.rs @@ -0,0 +1,428 @@ +#![no_std] + +mod access_control; +mod error; +mod events; +mod interface; +mod metadata; +mod royalty; +mod storage; +mod token; +mod transfer; +mod utils; + +use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, String, Vec}; + +use access_control::{has_role, require_owner, require_role, Role}; +use error::ContractError; +use events::{emit_approval, emit_approval_for_all, emit_base_uri_update, emit_burn, emit_metadata_update, emit_mint, emit_pause, emit_role_grant, emit_role_revoke, emit_transfer}; +use interface::supports_interface_id; +use metadata::{metadata_locked, require_metadata_updater, set_base_uri, set_token_uri, token_metadata, token_uri}; +use royalty::{get_royalty_info, set_default_royalty, set_token_royalty}; +use storage::{ + add_transfer_record, del_token, get_balance, get_collection_config, get_owner, get_reveal_time, + get_token, increment_balance, increment_token_count, is_initialized, next_token_id, set_balance, + set_collection_config, set_initialized, set_mint_paused, set_owner, set_paused, set_reveal_time, + set_token, set_transfer_paused, set_whitelist_enabled, set_whitelisted, token_count, +}; +use token::{CollectionConfig, RoyaltyInfo, TokenAttribute, TokenData}; +use transfer::{batch_transfer_internal, is_approved_for_all, owner_of, transfer_internal}; +use utils::{require_not_paused, require_token_exists, require_whitelist, validate_royalty_info}; + +#[contract] +pub struct NftContract; + +#[contractimpl] +impl NftContract { + pub fn init( + env: Env, + owner: Address, + name: String, + symbol: String, + base_uri: String, + max_supply: Option, + mint_price: Option, + royalty_recipient: Address, + royalty_percentage: u32, + ) -> Result<(), ContractError> { + if is_initialized(&env) { + return Err(ContractError::AlreadyInitialized); + } + owner.require_auth(); + validate_royalty_info(royalty_percentage)?; + + let config = CollectionConfig { + name, + symbol, + base_uri, + max_supply, + mint_price, + is_revealed: false, + royalty_default: RoyaltyInfo { + recipient: royalty_recipient, + percentage: royalty_percentage, + }, + metadata_is_frozen: false, + }; + + set_owner(&env, owner.clone()); + set_collection_config(&env, config); + set_initialized(&env, true); + + // Owner gets all roles by default. + access_control::set_role(&env, Role::Owner, owner.clone(), true); + access_control::set_role(&env, Role::Admin, owner.clone(), true); + access_control::set_role(&env, Role::Minter, owner.clone(), true); + access_control::set_role(&env, Role::Burner, owner.clone(), true); + access_control::set_role(&env, Role::MetadataUpdater, owner, true); + + Ok(()) + } + + pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) -> Result<(), ContractError> { + require_role(&env, Role::Owner)?; + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + pub fn name(env: Env) -> Result { + Ok(get_collection_config(&env)?.name) + } + + pub fn symbol(env: Env) -> Result { + Ok(get_collection_config(&env)?.symbol) + } + + pub fn get_collection_config(env: Env) -> Result { + get_collection_config(&env) + } + + pub fn owner(env: Env) -> Result { + get_owner(&env) + } + + pub fn transfer_ownership(env: Env, new_owner: Address) -> Result<(), ContractError> { + require_owner(&env)?; + new_owner.require_auth(); + set_owner(&env, new_owner); + Ok(()) + } + + pub fn grant_role(env: Env, role: Role, to: Address) -> Result<(), ContractError> { + require_role(&env, Role::Owner)?; + access_control::set_role(&env, role.clone(), to.clone(), true); + emit_role_grant(&env, role, to); + Ok(()) + } + + pub fn revoke_role(env: Env, role: Role, from: Address) -> Result<(), ContractError> { + require_role(&env, Role::Owner)?; + access_control::set_role(&env, role.clone(), from.clone(), false); + emit_role_revoke(&env, role, from); + Ok(()) + } + + pub fn has_role(env: Env, role: Role, addr: Address) -> Result { + Ok(has_role(&env, role, &addr)) + } + + pub fn set_paused(env: Env, paused: bool) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_paused(&env, paused); + emit_pause(&env, paused); + Ok(()) + } + + pub fn set_mint_paused(env: Env, paused: bool) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_mint_paused(&env, paused); + Ok(()) + } + + pub fn set_transfer_paused(env: Env, paused: bool) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_transfer_paused(&env, paused); + Ok(()) + } + + pub fn set_whitelist_enabled(env: Env, enabled: bool) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_whitelist_enabled(&env, enabled); + Ok(()) + } + + pub fn set_whitelisted(env: Env, addr: Address, enabled: bool) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_whitelisted(&env, addr, enabled); + Ok(()) + } + + pub fn is_whitelisted(env: Env, addr: Address) -> Result { + Ok(storage::is_whitelisted(&env, &addr)) + } + + pub fn set_reveal_time(env: Env, timestamp: u64) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_reveal_time(&env, timestamp); + Ok(()) + } + + pub fn reveal(env: Env) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + let now = env.ledger().timestamp(); + let reveal_time = get_reveal_time(&env)?; + if now < reveal_time { + return Err(ContractError::RevealNotReady); + } + let mut config = get_collection_config(&env)?; + config.is_revealed = true; + set_collection_config(&env, config); + Ok(()) + } + + pub fn mint( + env: Env, + to: Address, + metadata_uri: String, + attributes: Vec, + royalty_override: Option, + ) -> Result { + require_role(&env, Role::Minter)?; + require_not_paused(&env, true)?; + require_whitelist(&env, &to)?; + + let config = get_collection_config(&env)?; + if let Some(max_supply) = config.max_supply { + if token_count(&env) >= max_supply { + return Err(ContractError::MaxSupplyReached); + } + } + + let token_id = next_token_id(&env); + let creator = env.invoker(); + let royalty_info = royalty_override.unwrap_or(config.royalty_default.clone()); + validate_royalty_info(royalty_info.percentage)?; + + let token = TokenData { + id: token_id, + owner: to.clone(), + approved: None, + metadata_uri, + created_at: env.ledger().timestamp(), + creator: creator.clone(), + royalty_percentage: royalty_info.percentage, + royalty_recipient: royalty_info.recipient, + attributes, + edition_number: None, + total_editions: None, + }; + + set_token(&env, token_id, token); + increment_balance(&env, &to, 1); + add_transfer_record(&env, token_id, &creator, &to); + emit_mint(&env, to.clone(), token_id); + emit_transfer(&env, creator, to, token_id); + increment_token_count(&env); + + Ok(token_id) + } + + pub fn batch_mint( + env: Env, + recipients: Vec
, + metadata_uris: Vec, + attributes: Vec>, + ) -> Result, ContractError> { + require_role(&env, Role::Minter)?; + require_not_paused(&env, true)?; + + if recipients.len() != metadata_uris.len() || recipients.len() != attributes.len() { + return Err(ContractError::InvalidBatchLength); + } + + let mut ids: Vec = Vec::new(&env); + for idx in 0..recipients.len() { + let to = recipients.get_unchecked(idx); + require_whitelist(&env, &to)?; + let uri = metadata_uris.get_unchecked(idx); + let attrs = attributes.get_unchecked(idx); + let id = Self::mint(env.clone(), to, uri, attrs, None)?; + ids.push_back(id); + } + + Ok(ids) + } + + pub fn burn(env: Env, token_id: u64, confirm: bool) -> Result<(), ContractError> { + if !confirm { + return Err(ContractError::BurnNotConfirmed); + } + require_not_paused(&env, false)?; + require_token_exists(&env, token_id)?; + + let token = get_token(&env, token_id)?; + let operator = env.invoker(); + if !has_role(&env, Role::Burner, &operator) { + transfer::require_approved_or_owner(&env, &operator, token_id, &token.owner)?; + } + operator.require_auth(); + + del_token(&env, token_id); + let balance = get_balance(&env, &token.owner)?; + set_balance(&env, &token.owner, balance.saturating_sub(1)); + emit_burn(&env, token.owner, token_id); + + Ok(()) + } + + pub fn transfer(env: Env, from: Address, to: Address, token_id: u64) -> Result<(), ContractError> { + let operator = env.invoker(); + operator.require_auth(); + transfer_internal(&env, from, to, token_id, operator, false, None)?; + Ok(()) + } + + pub fn safe_transfer_from( + env: Env, + from: Address, + to: Address, + token_id: u64, + data: Option, + ) -> Result<(), ContractError> { + let operator = env.invoker(); + operator.require_auth(); + transfer_internal(&env, from, to, token_id, operator, true, data)?; + Ok(()) + } + + pub fn batch_transfer( + env: Env, + from: Address, + to: Address, + token_ids: Vec, + ) -> Result<(), ContractError> { + let operator = env.invoker(); + operator.require_auth(); + batch_transfer_internal(&env, from, to, token_ids, operator)?; + Ok(()) + } + + pub fn owner_of(env: Env, token_id: u64) -> Result { + owner_of(&env, token_id) + } + + pub fn balance_of(env: Env, owner: Address) -> Result { + get_balance(&env, &owner) + } + + pub fn approve(env: Env, to: Address, token_id: u64) -> Result<(), ContractError> { + require_not_paused(&env, false)?; + require_token_exists(&env, token_id)?; + let mut token = get_token(&env, token_id)?; + let operator = env.invoker(); + transfer::require_owner_or_operator(&env, &operator, &token.owner)?; + operator.require_auth(); + + token.approved = Some(to.clone()); + set_token(&env, token_id, token); + emit_approval(&env, to, token_id); + Ok(()) + } + + pub fn get_approved(env: Env, token_id: u64) -> Result, ContractError> { + require_token_exists(&env, token_id)?; + let token = get_token(&env, token_id)?; + Ok(token.approved) + } + + pub fn set_approval_for_all( + env: Env, + owner: Address, + operator: Address, + approved: bool, + ) -> Result<(), ContractError> { + owner.require_auth(); + storage::set_operator_approval(&env, &owner, &operator, approved); + emit_approval_for_all(&env, owner, operator, approved); + Ok(()) + } + + pub fn is_approved_for_all( + env: Env, + owner: Address, + operator: Address, + ) -> Result { + Ok(is_approved_for_all(&env, &owner, &operator)) + } + + pub fn token_uri(env: Env, token_id: u64) -> Result { + token_uri(&env, token_id) + } + + pub fn token_metadata(env: Env, token_id: u64) -> Result { + token_metadata(&env, token_id) + } + + pub fn set_token_uri(env: Env, token_id: u64, uri: String) -> Result<(), ContractError> { + require_metadata_updater(&env, token_id)?; + set_token_uri(&env, token_id, uri)?; + emit_metadata_update(&env, token_id); + Ok(()) + } + + pub fn set_base_uri(env: Env, uri: String) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + if metadata_locked(&env)? { + return Err(ContractError::MetadataFrozen); + } + set_base_uri(&env, uri.clone())?; + emit_base_uri_update(&env, uri); + Ok(()) + } + + pub fn freeze_metadata(env: Env) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + if metadata_locked(&env)? { + return Err(ContractError::MetadataFrozen); + } + let config = get_collection_config(&env)?; + config.metadata_is_frozen = true; + set_collection_config(&env, config); + Ok(()) + } + + pub fn set_default_royalty( + env: Env, + recipient: Address, + percentage: u32, + ) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_default_royalty(&env, recipient, percentage) + } + + pub fn set_royalty_info( + env: Env, + token_id: u64, + recipient: Address, + percentage: u32, + ) -> Result<(), ContractError> { + require_role(&env, Role::Admin)?; + set_token_royalty(&env, token_id, recipient, percentage) + } + + pub fn get_royalty_info( + env: Env, + token_id: u64, + sale_price: i128, + ) -> Result<(Address, i128), ContractError> { + get_royalty_info(&env, token_id, sale_price) + } + + pub fn get_transfer_history(env: Env, token_id: u64) -> Result, ContractError> { + storage::get_transfer_history(&env, token_id) + } + + pub fn supports_interface(env: Env, interface_id: soroban_sdk::Symbol) -> bool { + supports_interface_id(&env, interface_id) + } +} diff --git a/nftopia-stellar/contracts/nft_contract/src/metadata.rs b/nftopia-stellar/contracts/nft_contract/src/metadata.rs new file mode 100644 index 00000000..a75a9ab1 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/metadata.rs @@ -0,0 +1,65 @@ +use soroban_sdk::{Address, Env, String}; + +use crate::access_control::{has_role, Role}; +use crate::error::ContractError; +use crate::storage::{get_collection_config, get_token, set_collection_config, set_token}; +use crate::token::TokenData; + +pub fn metadata_locked(env: &Env) -> Result { + Ok(get_collection_config(env)?.metadata_is_frozen) +} + +pub fn require_metadata_updater(env: &Env, token_id: u64) -> Result<(), ContractError> { + let token = get_token(env, token_id)?; + let invoker = env.invoker(); + invoker.require_auth(); + + if invoker == token.owner + || has_role(env, Role::Admin, &invoker) + || has_role(env, Role::MetadataUpdater, &invoker) + { + return Ok(()); + } + + Err(ContractError::Unauthorized) +} + +pub fn token_uri(env: &Env, token_id: u64) -> Result { + let token = get_token(env, token_id)?; + let config = get_collection_config(env)?; + + if !config.is_revealed && config.base_uri.len() > 0 { + return Ok(config.base_uri); + } + + if token.metadata_uri.len() == 0 { + return Ok(config.base_uri); + } + + Ok(token.metadata_uri) +} + +pub fn token_metadata(env: &Env, token_id: u64) -> Result { + get_token(env, token_id) +} + +pub fn set_token_uri(env: &Env, token_id: u64, uri: String) -> Result<(), ContractError> { + let mut token = get_token(env, token_id)?; + let config = get_collection_config(env)?; + + if config.metadata_is_frozen { + return Err(ContractError::MetadataFrozen); + } + + token.metadata_uri = uri; + set_token(env, token_id, token); + + Ok(()) +} + +pub fn set_base_uri(env: &Env, uri: String) -> Result<(), ContractError> { + let mut config = get_collection_config(env)?; + config.base_uri = uri; + set_collection_config(env, config); + Ok(()) +} diff --git a/nftopia-stellar/contracts/nft_contract/src/royalty.rs b/nftopia-stellar/contracts/nft_contract/src/royalty.rs new file mode 100644 index 00000000..f692da07 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/royalty.rs @@ -0,0 +1,50 @@ +use soroban_sdk::{Address, Env}; + +use crate::error::ContractError; +use crate::storage::{get_collection_config, get_token, set_collection_config, set_token}; +use crate::token::RoyaltyInfo; +use crate::utils::validate_royalty_info; + +pub fn set_default_royalty( + env: &Env, + recipient: Address, + percentage: u32, +) -> Result<(), ContractError> { + validate_royalty_info(percentage)?; + let mut config = get_collection_config(env)?; + config.royalty_default = RoyaltyInfo { + recipient, + percentage, + }; + set_collection_config(env, config); + Ok(()) +} + +pub fn set_token_royalty( + env: &Env, + token_id: u64, + recipient: Address, + percentage: u32, +) -> Result<(), ContractError> { + validate_royalty_info(percentage)?; + let mut token = get_token(env, token_id)?; + token.royalty_recipient = recipient; + token.royalty_percentage = percentage; + set_token(env, token_id, token); + Ok(()) +} + +pub fn get_royalty_info( + env: &Env, + token_id: u64, + sale_price: i128, +) -> Result<(Address, i128), ContractError> { + if sale_price < 0 { + return Err(ContractError::InvalidSalePrice); + } + let token = get_token(env, token_id)?; + let amount = sale_price + .saturating_mul(token.royalty_percentage as i128) + .saturating_div(10_000); + Ok((token.royalty_recipient, amount)) +} diff --git a/nftopia-stellar/contracts/nft_contract/src/storage.rs b/nftopia-stellar/contracts/nft_contract/src/storage.rs new file mode 100644 index 00000000..56e78f86 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/storage.rs @@ -0,0 +1,202 @@ +use soroban_sdk::{contracttype, Address, Env, Vec}; + +use crate::error::ContractError; +use crate::token::{CollectionConfig, TokenData, TransferRecord}; +use crate::access_control::Role; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Initialized, + Owner, + CollectionConfig, + NextTokenId, + TokenCount, + Token(u64), + Balance(Address), + OperatorApproval(Address, Address), + Role(Role, Address), + Paused, + MintPaused, + TransferPaused, + WhitelistEnabled, + Whitelist(Address), + RevealTime, + TransferHistory(u64), + Reentrancy, +} + +pub fn is_initialized(env: &Env) -> bool { + env.storage().instance().get(&DataKey::Initialized).unwrap_or(false) +} + +pub fn set_initialized(env: &Env, value: bool) { + env.storage().instance().set(&DataKey::Initialized, &value); +} + +pub fn get_owner(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::Owner) + .ok_or(ContractError::NotInitialized) +} + +pub fn set_owner(env: &Env, owner: Address) { + env.storage().instance().set(&DataKey::Owner, &owner); +} + +pub fn get_collection_config(env: &Env) -> Result { + env.storage() + .instance() + .get(&DataKey::CollectionConfig) + .ok_or(ContractError::NotInitialized) +} + +pub fn set_collection_config(env: &Env, config: CollectionConfig) { + env.storage() + .instance() + .set(&DataKey::CollectionConfig, &config); +} + +pub fn next_token_id(env: &Env) -> u64 { + let current: u64 = env.storage().instance().get(&DataKey::NextTokenId).unwrap_or(1); + env.storage().instance().set(&DataKey::NextTokenId, &(current + 1)); + current +} + +pub fn token_count(env: &Env) -> u64 { + env.storage().instance().get(&DataKey::TokenCount).unwrap_or(0) +} + +pub fn increment_token_count(env: &Env) { + let count = token_count(env) + 1; + env.storage().instance().set(&DataKey::TokenCount, &count); +} + +pub fn get_token(env: &Env, token_id: u64) -> Result { + env.storage() + .persistent() + .get(&DataKey::Token(token_id)) + .ok_or(ContractError::TokenNotFound) +} + +pub fn set_token(env: &Env, token_id: u64, token: TokenData) { + env.storage().persistent().set(&DataKey::Token(token_id), &token); +} + +pub fn del_token(env: &Env, token_id: u64) { + env.storage().persistent().remove(&DataKey::Token(token_id)); +} + +pub fn get_balance(env: &Env, owner: &Address) -> Result { + Ok(env.storage().persistent().get(&DataKey::Balance(owner.clone())).unwrap_or(0)) +} + +pub fn set_balance(env: &Env, owner: &Address, balance: u64) { + env.storage().persistent().set(&DataKey::Balance(owner.clone()), &balance); +} + +pub fn increment_balance(env: &Env, owner: &Address, delta: u64) { + let balance = get_balance(env, owner).unwrap_or(0).saturating_add(delta); + set_balance(env, owner, balance); +} + +pub fn set_operator_approval(env: &Env, owner: &Address, operator: &Address, approved: bool) { + env.storage() + .persistent() + .set(&DataKey::OperatorApproval(owner.clone(), operator.clone()), &approved); +} + +pub fn get_operator_approval(env: &Env, owner: &Address, operator: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::OperatorApproval(owner.clone(), operator.clone())) + .unwrap_or(false) +} + +pub fn set_role_entry(env: &Env, role: Role, addr: &Address, enabled: bool) { + env.storage() + .persistent() + .set(&DataKey::Role(role, addr.clone()), &enabled); +} + +pub fn has_role_entry(env: &Env, role: Role, addr: &Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Role(role, addr.clone())) + .unwrap_or(false) +} + +pub fn is_paused(env: &Env) -> bool { + env.storage().instance().get(&DataKey::Paused).unwrap_or(false) +} + +pub fn set_paused(env: &Env, paused: bool) { + env.storage().instance().set(&DataKey::Paused, &paused); +} + +pub fn is_mint_paused(env: &Env) -> bool { + env.storage().instance().get(&DataKey::MintPaused).unwrap_or(false) +} + +pub fn set_mint_paused(env: &Env, paused: bool) { + env.storage().instance().set(&DataKey::MintPaused, &paused); +} + +pub fn is_transfer_paused(env: &Env) -> bool { + env.storage().instance().get(&DataKey::TransferPaused).unwrap_or(false) +} + +pub fn set_transfer_paused(env: &Env, paused: bool) { + env.storage().instance().set(&DataKey::TransferPaused, &paused); +} + +pub fn whitelist_enabled(env: &Env) -> bool { + env.storage().instance().get(&DataKey::WhitelistEnabled).unwrap_or(false) +} + +pub fn set_whitelist_enabled(env: &Env, enabled: bool) { + env.storage().instance().set(&DataKey::WhitelistEnabled, &enabled); +} + +pub fn set_whitelisted(env: &Env, addr: Address, enabled: bool) { + env.storage().persistent().set(&DataKey::Whitelist(addr), &enabled); +} + +pub fn is_whitelisted(env: &Env, addr: &Address) -> bool { + env.storage().persistent().get(&DataKey::Whitelist(addr.clone())).unwrap_or(false) +} + +pub fn set_reveal_time(env: &Env, timestamp: u64) { + env.storage().instance().set(&DataKey::RevealTime, ×tamp); +} + +pub fn get_reveal_time(env: &Env) -> Result { + Ok(env.storage().instance().get(&DataKey::RevealTime).unwrap_or(0)) +} + +pub fn get_transfer_history(env: &Env, token_id: u64) -> Result, ContractError> { + Ok(env + .storage() + .persistent() + .get(&DataKey::TransferHistory(token_id)) + .unwrap_or(Vec::new(env))) +} + +pub fn add_transfer_record(env: &Env, token_id: u64, from: &Address, to: &Address) { + let mut history = get_transfer_history(env, token_id).unwrap_or(Vec::new(env)); + history.push_back(TransferRecord { + from: from.clone(), + to: to.clone(), + timestamp: env.ledger().timestamp(), + }); + env.storage().persistent().set(&DataKey::TransferHistory(token_id), &history); +} + +pub fn is_reentrancy_active(env: &Env) -> bool { + env.storage().instance().get(&DataKey::Reentrancy).unwrap_or(false) +} + +pub fn set_reentrancy(env: &Env, active: bool) { + env.storage().instance().set(&DataKey::Reentrancy, &active); +} diff --git a/nftopia-stellar/contracts/nft_contract/src/test.rs b/nftopia-stellar/contracts/nft_contract/src/test.rs index e69de29b..387da86e 100644 --- a/nftopia-stellar/contracts/nft_contract/src/test.rs +++ b/nftopia-stellar/contracts/nft_contract/src/test.rs @@ -0,0 +1,102 @@ +#![cfg(test)] +extern crate std; + +use soroban_sdk::{Env, String, Vec}; +use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; + +use crate::{NftContract, NftContractClient}; +use crate::token::{TokenAttribute, RoyaltyInfo}; + +fn setup_env() -> Env { + let env = Env::default(); + env.ledger().set(LedgerInfo { + timestamp: 1, + ..Default::default() + }); + env.mock_all_auths(); + env +} + +#[test] +fn mint_and_transfer_flow() { + let env = setup_env(); + let owner = soroban_sdk::Address::generate(&env); + let alice = soroban_sdk::Address::generate(&env); + let bob = soroban_sdk::Address::generate(&env); + + let contract_id = env.register_contract(None, NftContract); + let client = NftContractClient::new(&env, &contract_id); + + client.init( + &owner, + &String::from_str(&env, "NFTopia"), + &String::from_str(&env, "NFT"), + &String::from_str(&env, "ipfs://base"), + &None, + &None, + &owner, + &500, + ); + + let mut attrs = Vec::new(&env); + attrs.push_back(TokenAttribute { + trait_type: String::from_str(&env, "color"), + value: String::from_str(&env, "red"), + display_type: None, + }); + + let token_id = client.mint( + &alice, + &String::from_str(&env, "ipfs://token/1"), + &attrs, + &None, + ); + + assert_eq!(client.owner_of(&token_id), alice); + assert_eq!(client.balance_of(&alice), 1); + + client.approve(&bob, &token_id); + client.transfer(&alice, &bob, &token_id); + + assert_eq!(client.owner_of(&token_id), bob); + assert_eq!(client.balance_of(&alice), 0); + assert_eq!(client.balance_of(&bob), 1); +} + +#[test] +fn royalty_calculation() { + let env = setup_env(); + let owner = soroban_sdk::Address::generate(&env); + let alice = soroban_sdk::Address::generate(&env); + + let contract_id = env.register_contract(None, NftContract); + let client = NftContractClient::new(&env, &contract_id); + + client.init( + &owner, + &String::from_str(&env, "NFTopia"), + &String::from_str(&env, "NFT"), + &String::from_str(&env, ""), + &None, + &None, + &owner, + &1000, + ); + + let attrs = Vec::new(&env); + let royalty_override = RoyaltyInfo { + recipient: owner.clone(), + percentage: 750, + }; + + let token_id = client.mint( + &alice, + &String::from_str(&env, "ipfs://token/2"), + &attrs, + &Some(royalty_override), + ); + + let (recipient, amount) = client.get_royalty_info(&token_id, &10_000i128); + assert_eq!(recipient, owner); + assert_eq!(amount, 750); +} diff --git a/nftopia-stellar/contracts/nft_contract/src/token.rs b/nftopia-stellar/contracts/nft_contract/src/token.rs new file mode 100644 index 00000000..f7e810d1 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/token.rs @@ -0,0 +1,53 @@ +use soroban_sdk::{contracttype, Address, String, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenAttribute { + pub trait_type: String, + pub value: String, + pub display_type: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoyaltyInfo { + pub recipient: Address, + pub percentage: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CollectionConfig { + pub name: String, + pub symbol: String, + pub base_uri: String, + pub max_supply: Option, + pub mint_price: Option, + pub is_revealed: bool, + pub royalty_default: RoyaltyInfo, + pub metadata_is_frozen: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TokenData { + pub id: u64, + pub owner: Address, + pub approved: Option
, + pub metadata_uri: String, + pub created_at: u64, + pub creator: Address, + pub royalty_percentage: u32, + pub royalty_recipient: Address, + pub attributes: Vec, + pub edition_number: Option, + pub total_editions: Option, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferRecord { + pub from: Address, + pub to: Address, + pub timestamp: u64, +} diff --git a/nftopia-stellar/contracts/nft_contract/src/transfer.rs b/nftopia-stellar/contracts/nft_contract/src/transfer.rs new file mode 100644 index 00000000..9f641e51 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/transfer.rs @@ -0,0 +1,108 @@ +use soroban_sdk::{Address, Bytes, Env, Symbol}; + +use crate::error::ContractError; +use crate::events::emit_transfer; +use crate::storage::{ + add_transfer_record, get_balance, get_operator_approval, get_token, set_balance, set_token, +}; +use crate::utils::{require_not_paused, require_token_exists, with_reentrancy_guard}; + +pub fn owner_of(env: &Env, token_id: u64) -> Result { + let token = get_token(env, token_id)?; + Ok(token.owner) +} + +pub fn is_approved_for_all(env: &Env, owner: &Address, operator: &Address) -> bool { + get_operator_approval(env, owner, operator) +} + +pub fn require_owner_or_operator( + env: &Env, + operator: &Address, + owner: &Address, +) -> Result<(), ContractError> { + if operator == owner || is_approved_for_all(env, owner, operator) { + return Ok(()); + } + Err(ContractError::Unauthorized) +} + +pub fn require_approved_or_owner( + env: &Env, + operator: &Address, + token_id: u64, + owner: &Address, +) -> Result<(), ContractError> { + let token = get_token(env, token_id)?; + if operator == owner + || token.approved.as_ref() == Some(operator) + || is_approved_for_all(env, owner, operator) + { + return Ok(()); + } + Err(ContractError::Unauthorized) +} + +pub fn transfer_internal( + env: &Env, + from: Address, + to: Address, + token_id: u64, + operator: Address, + safe_transfer: bool, + data: Option, +) -> Result<(), ContractError> { + require_not_paused(env, false)?; + require_token_exists(env, token_id)?; + + let mut token = get_token(env, token_id)?; + if token.owner != from { + return Err(ContractError::Unauthorized); + } + require_approved_or_owner(env, &operator, token_id, &from)?; + + let from_balance = get_balance(env, &from)?; + if from_balance == 0 { + return Err(ContractError::Unauthorized); + } + + set_balance(env, &from, from_balance.saturating_sub(1)); + let to_balance = get_balance(env, &to)?; + set_balance(env, &to, to_balance.saturating_add(1)); + + token.owner = to.clone(); + token.approved = None; + set_token(env, token_id, token); + + add_transfer_record(env, token_id, &from, &to); + emit_transfer(env, from.clone(), to.clone(), token_id); + + if safe_transfer && to.is_contract(env) { + with_reentrancy_guard(env, || { + let response: bool = env.invoke_contract( + &to, + &Symbol::new(env, "on_nft_received"), + (operator, from, token_id, data), + ); + if !response { + return Err(ContractError::Unauthorized); + } + Ok(()) + })?; + } + + Ok(()) +} + +pub fn batch_transfer_internal( + env: &Env, + from: Address, + to: Address, + token_ids: soroban_sdk::Vec, + operator: Address, +) -> Result<(), ContractError> { + for token_id in token_ids.iter() { + transfer_internal(env, from.clone(), to.clone(), token_id, operator.clone(), false, None)?; + } + Ok(()) +} diff --git a/nftopia-stellar/contracts/nft_contract/src/utils.rs b/nftopia-stellar/contracts/nft_contract/src/utils.rs new file mode 100644 index 00000000..69379de1 --- /dev/null +++ b/nftopia-stellar/contracts/nft_contract/src/utils.rs @@ -0,0 +1,55 @@ +use soroban_sdk::Env; + +use crate::error::ContractError; +use crate::storage::{ + get_token, is_mint_paused, is_paused, is_reentrancy_active, is_transfer_paused, is_whitelisted, + set_reentrancy, whitelist_enabled, +}; + +pub fn require_not_paused(env: &Env, minting: bool) -> Result<(), ContractError> { + if is_paused(env) { + return Err(ContractError::ContractPaused); + } + if minting && is_mint_paused(env) { + return Err(ContractError::MintingPaused); + } + if !minting && is_transfer_paused(env) { + return Err(ContractError::TransfersPaused); + } + Ok(()) +} + +pub fn validate_royalty_info(percentage: u32) -> Result<(), ContractError> { + if percentage > 10_000 { + return Err(ContractError::InvalidRoyaltyPercentage); + } + Ok(()) +} + +pub fn require_token_exists(env: &Env, token_id: u64) -> Result<(), ContractError> { + get_token(env, token_id)?; + Ok(()) +} + +pub fn require_whitelist(env: &Env, addr: &soroban_sdk::Address) -> Result<(), ContractError> { + if !whitelist_enabled(env) { + return Ok(()); + } + if !is_whitelisted(env, addr) { + return Err(ContractError::NotWhitelisted); + } + Ok(()) +} + +pub fn with_reentrancy_guard(env: &Env, mut action: F) -> Result<(), ContractError> +where + F: FnMut() -> Result<(), ContractError>, +{ + if is_reentrancy_active(env) { + return Err(ContractError::ReentrancyDetected); + } + set_reentrancy(env, true); + let result = action(); + set_reentrancy(env, false); + result +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c2ca4a0..c0046e13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,7 +4,7 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -pnpmfileChecksum: sha256-d9h7GHTK6Upl6dlfXe0a45m8LcOjX8MJvpHcxQVQwAI= +pnpmfileChecksum: o2geuoxuxqvnlmcrofkjdv7af4 importers: @@ -15,7 +15,7 @@ importers: version: 4.0.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/typeorm': specifier: ^11.0.0 - version: 11.0.0(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.25(ioredis@5.6.1)(reflect-metadata@0.2.2)) + version: 11.0.0(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.25(ioredis@5.6.1)(reflect-metadata@0.2.2)) '@nestjs/websockets': specifier: ^11.1.2 version: 11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -67,7 +67,7 @@ importers: version: 11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/testing': specifier: ^11.1.5 - version: 11.1.5(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2) + version: 11.1.5(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -2712,13 +2712,13 @@ snapshots: optionalDependencies: '@nestjs/websockets': 11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/testing@11.1.5(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)': + '@nestjs/testing@11.1.5(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.25(ioredis@5.6.1)(reflect-metadata@0.2.2))': + '@nestjs/typeorm@11.0.0(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.25(ioredis@5.6.1)(reflect-metadata@0.2.2))': dependencies: '@nestjs/common': 11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.2(@nestjs/common@11.1.5(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)