From 125f82eb21da93dac4e0dcfa177f03a788b766ba Mon Sep 17 00:00:00 2001 From: lynn Date: Fri, 20 Feb 2026 00:16:08 +0100 Subject: [PATCH] fractional ownership enabled --- Cargo.toml | 1 + contracts/achievement_nft/src/lib.rs | 30 +- contracts/fractional_nft/Cargo.toml | 16 + contracts/fractional_nft/src/lib.rs | 879 +++++++++++++++++++++++++++ contracts/fractional_nft/src/test.rs | 358 +++++++++++ 5 files changed, 1274 insertions(+), 10 deletions(-) create mode 100644 contracts/fractional_nft/Cargo.toml create mode 100644 contracts/fractional_nft/src/lib.rs create mode 100644 contracts/fractional_nft/src/test.rs diff --git a/Cargo.toml b/Cargo.toml index 8777313..827a8ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/subscription", "contracts/achievement_collection", "contracts/achievement_nft", + "contracts/fractional_nft", "contracts/auction", "contracts/bridge", "contracts/crafting", diff --git a/contracts/achievement_nft/src/lib.rs b/contracts/achievement_nft/src/lib.rs index 883ef6a..24953c0 100644 --- a/contracts/achievement_nft/src/lib.rs +++ b/contracts/achievement_nft/src/lib.rs @@ -1,12 +1,8 @@ #![no_std] -<<<<<<< Updated upstream + use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec, }; -======= - -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String}; ->>>>>>> Stashed changes #[contracttype] #[derive(Clone)] @@ -42,20 +38,34 @@ impl AchievementNFT { env.storage().instance().set(&DataKey::TotalSupply, &0u32); } -<<<<<<< Updated upstream - /// Admin function to mark a puzzle as completed for a user. + /// Mark a puzzle as completed by a user. pub fn mark_puzzle_completed(env: Env, user: Address, puzzle_id: u32) { let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); admin.require_auth(); + let key = DataKey::PuzzleCompleted(user.clone(), puzzle_id); + env.storage() + .persistent() + .set(&key, &true); env.storage() .persistent() - .set(&DataKey::PuzzleCompleted(user, puzzle_id), &true); + .extend_ttl(&key, 100_000, 500_000); } -======= + /// Mint a new achievement NFT pub fn mint(env: Env, to: Address, puzzle_id: u32, metadata: String) -> u32 { to.require_auth(); ->>>>>>> Stashed changes + + let completed: bool = env + .storage() + .persistent() + .get(&DataKey::PuzzleCompleted(to.clone(), puzzle_id)) + .unwrap_or(false); + if !completed { + panic!("Puzzle not completed"); + } + + Self::craftmint(env, to, puzzle_id, metadata) + } /// Mint a new NFT for crafting purposes (testnet: no auth required). pub fn craftmint(env: Env, to: Address, puzzle_id: u32, metadata: String) -> u32 { diff --git a/contracts/fractional_nft/Cargo.toml b/contracts/fractional_nft/Cargo.toml new file mode 100644 index 0000000..bebc784 --- /dev/null +++ b/contracts/fractional_nft/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fractional-nft" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/fractional_nft/src/lib.rs b/contracts/fractional_nft/src/lib.rs new file mode 100644 index 0000000..1bbe321 --- /dev/null +++ b/contracts/fractional_nft/src/lib.rs @@ -0,0 +1,879 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, token, Address, Env, IntoVal, Symbol, Vec, +}; + +const BPS_DENOMINATOR: i128 = 10_000; +const ACC_SCALE: i128 = 1_000_000_000_000; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Vault { + pub id: u64, + pub nft_contract: Address, + pub nft_id: u32, + pub total_shares: i128, + pub min_ownership_bps: u32, + pub rental_token: Option
, + pub acc_rental_per_share: i128, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Listing { + pub id: u64, + pub vault_id: u64, + pub seller: Address, + pub payment_token: Address, + pub shares: i128, + pub price_per_share: i128, + pub expiration: Option, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Buyout { + pub vault_id: u64, + pub buyer: Address, + pub payment_token: Address, + pub price_total: i128, + pub escrow_remaining: i128, + pub end_time: u64, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProposalKind { + SetRentalToken = 1, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Proposal { + pub id: u64, + pub vault_id: u64, + pub proposer: Address, + pub kind: ProposalKind, + pub new_rental_token: Address, + pub start_time: u64, + pub end_time: u64, + pub for_votes: i128, + pub against_votes: i128, + pub executed: bool, +} + +#[contracttype] +pub enum DataKey { + Admin, + VaultCount, + Vault(u64), + + Balance(u64, Address), + Allowance(u64, Address, Address), + + ListingCount, + Listing(u64), + + Buyout(u64), + + ProposalCount, + Proposal(u64), + Voted(u64, Address), + + RewardDebt(u64, Address), + Claimable(u64, Address), +} + +#[contract] +pub struct FractionalNftContract; + +#[contractimpl] +impl FractionalNftContract { + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("Already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::VaultCount, &0u64); + env.storage().instance().set(&DataKey::ListingCount, &0u64); + env.storage().instance().set(&DataKey::ProposalCount, &0u64); + } + + pub fn distribute_shares( + env: Env, + vault_id: u64, + from: Address, + recipients: Vec
, + amounts: Vec, + ) { + from.require_auth(); + Self::require_active_vault(&env, vault_id); + + if recipients.len() != amounts.len() { + panic!("length_mismatch"); + } + + for i in 0..recipients.len() { + let to = recipients.get(i).unwrap(); + let amt = amounts.get(i).unwrap(); + if amt <= 0 { + panic!("invalid_amount"); + } + Self::transfer_shares_internal(&env, vault_id, &from, &to, amt); + } + } + + pub fn fractionalize( + env: Env, + owner: Address, + nft_contract: Address, + nft_id: u32, + total_shares: i128, + min_ownership_bps: u32, + rental_token: Option
, + ) -> u64 { + owner.require_auth(); + + if total_shares <= 0 { + panic!("invalid_total_shares"); + } + if min_ownership_bps > 10_000 { + panic!("invalid_min_ownership_bps"); + } + + let owner_of_args = (nft_id,).into_val(&env); + let current_owner: Address = env.invoke_contract( + &nft_contract, + &Symbol::new(&env, "owner_of"), + owner_of_args, + ); + if current_owner != owner { + panic!("not_nft_owner"); + } + + let transfer_args = (owner.clone(), env.current_contract_address(), nft_id).into_val(&env); + env.invoke_contract::<()>( + &nft_contract, + &Symbol::new(&env, "transfer"), + transfer_args, + ); + + let id = Self::next_vault_id(&env); + + let vault = Vault { + id, + nft_contract, + nft_id, + total_shares, + min_ownership_bps, + rental_token, + acc_rental_per_share: 0, + active: true, + }; + + env.storage().persistent().set(&DataKey::Vault(id), &vault); + env.storage() + .persistent() + .extend_ttl(&DataKey::Vault(id), 100_000, 500_000); + + Self::set_balance(&env, id, &owner, total_shares); + Self::set_reward_debt(&env, id, &owner, 0); + + env.events().publish((symbol_short!("fraction"), id), ()); + + id + } + + pub fn get_vault(env: Env, vault_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Vault(vault_id)) + } + + pub fn balance_of(env: Env, vault_id: u64, owner: Address) -> i128 { + Self::balance(&env, vault_id, &owner) + } + + pub fn allowance(env: Env, vault_id: u64, owner: Address, spender: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Allowance(vault_id, owner, spender)) + .unwrap_or(0) + } + + pub fn approve_shares(env: Env, vault_id: u64, owner: Address, spender: Address, amount: i128) { + owner.require_auth(); + if amount < 0 { + panic!("invalid_amount"); + } + env.storage() + .persistent() + .set(&DataKey::Allowance(vault_id, owner, spender), &amount); + } + + pub fn transfer_shares(env: Env, vault_id: u64, from: Address, to: Address, amount: i128) { + from.require_auth(); + if amount <= 0 { + panic!("invalid_amount"); + } + if from == to { + panic!("cannot_transfer_to_self"); + } + + Self::before_balance_change(&env, vault_id, &from); + Self::before_balance_change(&env, vault_id, &to); + + let from_bal = Self::balance(&env, vault_id, &from); + if from_bal < amount { + panic!("insufficient_balance"); + } + + let to_bal = Self::balance(&env, vault_id, &to); + Self::set_balance(&env, vault_id, &from, from_bal - amount); + Self::set_balance(&env, vault_id, &to, to_bal + amount); + + Self::enforce_min_threshold(&env, vault_id, &from); + Self::enforce_min_threshold(&env, vault_id, &to); + + Self::after_balance_change(&env, vault_id, &from); + Self::after_balance_change(&env, vault_id, &to); + + env.events() + .publish((symbol_short!("xfer"), vault_id, from, to), amount); + } + + pub fn transfer_shares_from( + env: Env, + vault_id: u64, + spender: Address, + from: Address, + to: Address, + amount: i128, + ) { + spender.require_auth(); + if amount <= 0 { + panic!("invalid_amount"); + } + + let key = DataKey::Allowance(vault_id, from.clone(), spender.clone()); + let allow: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if allow < amount { + panic!("insufficient_allowance"); + } + env.storage().persistent().set(&key, &(allow - amount)); + + Self::before_balance_change(&env, vault_id, &from); + Self::before_balance_change(&env, vault_id, &to); + + let from_bal = Self::balance(&env, vault_id, &from); + if from_bal < amount { + panic!("insufficient_balance"); + } + let to_bal = Self::balance(&env, vault_id, &to); + + Self::set_balance(&env, vault_id, &from, from_bal - amount); + Self::set_balance(&env, vault_id, &to, to_bal + amount); + + Self::enforce_min_threshold(&env, vault_id, &from); + Self::enforce_min_threshold(&env, vault_id, &to); + + Self::after_balance_change(&env, vault_id, &from); + Self::after_balance_change(&env, vault_id, &to); + } + + pub fn create_listing( + env: Env, + seller: Address, + vault_id: u64, + shares: i128, + payment_token: Address, + price_per_share: i128, + expiration: Option, + ) -> u64 { + seller.require_auth(); + Self::require_active_vault(&env, vault_id); + + if shares <= 0 || price_per_share <= 0 { + panic!("invalid_params"); + } + + if let Some(t) = expiration { + if t <= env.ledger().timestamp() { + panic!("invalid_expiration"); + } + } + + Self::transfer_shares_internal(&env, vault_id, &seller, &env.current_contract_address(), shares); + + let id = Self::next_listing_id(&env); + let listing = Listing { + id, + vault_id, + seller, + payment_token, + shares, + price_per_share, + expiration, + active: true, + }; + env.storage().persistent().set(&DataKey::Listing(id), &listing); + id + } + + pub fn buy_listing(env: Env, buyer: Address, listing_id: u64) { + buyer.require_auth(); + + let mut listing: Listing = env + .storage() + .persistent() + .get(&DataKey::Listing(listing_id)) + .unwrap_or_else(|| panic!("listing_not_found")); + + if !listing.active { + panic!("listing_inactive"); + } + if listing.seller == buyer { + panic!("cannot_buy_own_listing"); + } + if let Some(t) = listing.expiration { + if env.ledger().timestamp() > t { + panic!("listing_expired"); + } + } + + let total_price = listing + .price_per_share + .checked_mul(listing.shares) + .unwrap_or_else(|| panic!("overflow")); + + let token_client = token::Client::new(&env, &listing.payment_token); + token_client.transfer(&buyer, &listing.seller, &total_price); + + Self::transfer_shares_internal( + &env, + listing.vault_id, + &env.current_contract_address(), + &buyer, + listing.shares, + ); + + listing.active = false; + env.storage().persistent().set(&DataKey::Listing(listing_id), &listing); + + env.events() + .publish((symbol_short!("sold"), listing_id), total_price); + } + + pub fn cancel_listing(env: Env, seller: Address, listing_id: u64) { + seller.require_auth(); + + let mut listing: Listing = env + .storage() + .persistent() + .get(&DataKey::Listing(listing_id)) + .unwrap_or_else(|| panic!("listing_not_found")); + + if !listing.active { + panic!("listing_inactive"); + } + if listing.seller != seller { + panic!("not_seller"); + } + + Self::transfer_shares_internal( + &env, + listing.vault_id, + &env.current_contract_address(), + &seller, + listing.shares, + ); + + listing.active = false; + env.storage().persistent().set(&DataKey::Listing(listing_id), &listing); + } + + pub fn start_buyout( + env: Env, + buyer: Address, + vault_id: u64, + payment_token: Address, + price_total: i128, + end_time: u64, + ) { + buyer.require_auth(); + let vault = Self::require_active_vault(&env, vault_id); + + if price_total <= 0 { + panic!("invalid_price"); + } + if end_time <= env.ledger().timestamp() { + panic!("invalid_end_time"); + } + + if env.storage().persistent().has(&DataKey::Buyout(vault_id)) { + let existing: Buyout = env.storage().persistent().get(&DataKey::Buyout(vault_id)).unwrap(); + if existing.active { + panic!("buyout_active"); + } + } + + let token_client = token::Client::new(&env, &payment_token); + token_client.transfer(&buyer, &env.current_contract_address(), &price_total); + + let buyout = Buyout { + vault_id, + buyer, + payment_token, + price_total, + escrow_remaining: price_total, + end_time, + active: true, + }; + env.storage().persistent().set(&DataKey::Buyout(vault_id), &buyout); + + env.events() + .publish((symbol_short!("buyout"), vault_id), price_total); + + let _ = vault; + } + + pub fn get_buyout(env: Env, vault_id: u64) -> Option { + env.storage().persistent().get(&DataKey::Buyout(vault_id)) + } + + pub fn tender_shares(env: Env, holder: Address, vault_id: u64, shares: i128) { + holder.require_auth(); + if shares <= 0 { + panic!("invalid_amount"); + } + + let vault = Self::require_active_vault(&env, vault_id); + + let mut buyout: Buyout = env + .storage() + .persistent() + .get(&DataKey::Buyout(vault_id)) + .unwrap_or_else(|| panic!("no_buyout")); + + if !buyout.active { + panic!("buyout_inactive"); + } + if env.ledger().timestamp() > buyout.end_time { + panic!("buyout_ended"); + } + if holder == buyout.buyer { + panic!("buyer_cannot_tender"); + } + + let payout = buyout + .price_total + .checked_mul(shares) + .unwrap_or_else(|| panic!("overflow")) + / vault.total_shares; + + if payout > buyout.escrow_remaining { + panic!("insufficient_buyout_escrow"); + } + + Self::transfer_shares_internal(&env, vault_id, &holder, &buyout.buyer, shares); + + let token_client = token::Client::new(&env, &buyout.payment_token); + token_client.transfer(&env.current_contract_address(), &holder, &payout); + + buyout.escrow_remaining -= payout; + env.storage().persistent().set(&DataKey::Buyout(vault_id), &buyout); + + env.events() + .publish((symbol_short!("tender"), vault_id, holder), payout); + } + + pub fn reclaim_buyout_escrow(env: Env, buyer: Address, vault_id: u64) { + buyer.require_auth(); + let mut buyout: Buyout = env + .storage() + .persistent() + .get(&DataKey::Buyout(vault_id)) + .unwrap_or_else(|| panic!("no_buyout")); + + if !buyout.active { + panic!("buyout_inactive"); + } + if buyout.buyer != buyer { + panic!("not_buyer"); + } + if env.ledger().timestamp() <= buyout.end_time { + panic!("buyout_not_ended"); + } + + let token_client = token::Client::new(&env, &buyout.payment_token); + if buyout.escrow_remaining > 0 { + token_client.transfer( + &env.current_contract_address(), + &buyer, + &buyout.escrow_remaining, + ); + buyout.escrow_remaining = 0; + } + + buyout.active = false; + env.storage().persistent().set(&DataKey::Buyout(vault_id), &buyout); + } + + pub fn recombine(env: Env, owner: Address, vault_id: u64, to: Address) { + owner.require_auth(); + let mut vault = Self::require_active_vault(&env, vault_id); + + let bal = Self::balance(&env, vault_id, &owner); + if bal != vault.total_shares { + panic!("not_full_owner"); + } + + if let Some(buyout) = env.storage().persistent().get::(&DataKey::Buyout(vault_id)) { + if buyout.active { + panic!("buyout_active"); + } + } + + Self::before_balance_change(&env, vault_id, &owner); + Self::set_balance(&env, vault_id, &owner, 0); + Self::after_balance_change(&env, vault_id, &owner); + + let transfer_args = (env.current_contract_address(), to, vault.nft_id).into_val(&env); + env.invoke_contract::<()>( + &vault.nft_contract, + &Symbol::new(&env, "transfer"), + transfer_args, + ); + + vault.active = false; + env.storage().persistent().set(&DataKey::Vault(vault_id), &vault); + + env.events().publish((symbol_short!("merge"), vault_id), ()); + } + + pub fn deposit_rental_income(env: Env, payer: Address, vault_id: u64, amount: i128) { + payer.require_auth(); + let mut vault = Self::require_active_vault(&env, vault_id); + + if amount <= 0 { + panic!("invalid_amount"); + } + let token_addr = vault.rental_token.clone().unwrap_or_else(|| panic!("rental_token_not_set")); + + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer(&payer, &env.current_contract_address(), &amount); + + vault.acc_rental_per_share += amount + .checked_mul(ACC_SCALE) + .unwrap_or_else(|| panic!("overflow")) + / vault.total_shares; + + env.storage().persistent().set(&DataKey::Vault(vault_id), &vault); + } + + pub fn claim_rental_profit(env: Env, claimer: Address, vault_id: u64) { + claimer.require_auth(); + let vault = Self::require_active_vault(&env, vault_id); + + Self::before_balance_change(&env, vault_id, &claimer); + + let amount = env + .storage() + .persistent() + .get(&DataKey::Claimable(vault_id, claimer.clone())) + .unwrap_or(0); + if amount <= 0 { + panic!("no_claimable"); + } + env.storage() + .persistent() + .set(&DataKey::Claimable(vault_id, claimer.clone()), &0i128); + + let token_addr = vault.rental_token.unwrap_or_else(|| panic!("rental_token_not_set")); + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer(&env.current_contract_address(), &claimer, &amount); + + Self::after_balance_change(&env, vault_id, &claimer); + } + + pub fn create_proposal_set_rental_token( + env: Env, + proposer: Address, + vault_id: u64, + new_rental_token: Address, + voting_period_secs: u64, + ) -> u64 { + proposer.require_auth(); + let vault = Self::require_active_vault(&env, vault_id); + + if voting_period_secs == 0 { + panic!("invalid_voting_period"); + } + + let proposer_shares = Self::balance(&env, vault_id, &proposer); + if proposer_shares <= 0 { + panic!("no_shares"); + } + + let id = Self::next_proposal_id(&env); + let start_time = env.ledger().timestamp(); + let end_time = start_time + voting_period_secs; + + let proposal = Proposal { + id, + vault_id, + proposer, + kind: ProposalKind::SetRentalToken, + new_rental_token, + start_time, + end_time, + for_votes: 0, + against_votes: 0, + executed: false, + }; + + env.storage().persistent().set(&DataKey::Proposal(id), &proposal); + + let _ = vault; + + id + } + + pub fn vote(env: Env, voter: Address, proposal_id: u64, support: bool) { + voter.require_auth(); + + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic!("proposal_not_found")); + + let now = env.ledger().timestamp(); + if now < proposal.start_time { + panic!("voting_not_started"); + } + if now > proposal.end_time { + panic!("voting_ended"); + } + + if env + .storage() + .persistent() + .has(&DataKey::Voted(proposal_id, voter.clone())) + { + panic!("already_voted"); + } + + let weight = Self::balance(&env, proposal.vault_id, &voter); + if weight <= 0 { + panic!("no_shares"); + } + + if support { + proposal.for_votes += weight; + } else { + proposal.against_votes += weight; + } + + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + env.storage() + .persistent() + .set(&DataKey::Voted(proposal_id, voter), &true); + } + + pub fn execute_proposal(env: Env, proposal_id: u64) { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic!("proposal_not_found")); + + if proposal.executed { + panic!("already_executed"); + } + + let now = env.ledger().timestamp(); + if now <= proposal.end_time { + panic!("voting_not_ended"); + } + + if proposal.for_votes <= proposal.against_votes { + panic!("proposal_defeated"); + } + + let mut vault: Vault = env + .storage() + .persistent() + .get(&DataKey::Vault(proposal.vault_id)) + .unwrap_or_else(|| panic!("vault_not_found")); + + if !vault.active { + panic!("vault_inactive"); + } + + match proposal.kind { + ProposalKind::SetRentalToken => { + vault.rental_token = Some(proposal.new_rental_token.clone()); + env.storage().persistent().set(&DataKey::Vault(vault.id), &vault); + } + } + + proposal.executed = true; + env.storage().persistent().set(&DataKey::Proposal(proposal_id), &proposal); + } + + fn next_vault_id(env: &Env) -> u64 { + let mut id: u64 = env.storage().instance().get(&DataKey::VaultCount).unwrap_or(0); + id += 1; + env.storage().instance().set(&DataKey::VaultCount, &id); + id + } + + fn next_listing_id(env: &Env) -> u64 { + let mut id: u64 = env + .storage() + .instance() + .get(&DataKey::ListingCount) + .unwrap_or(0); + id += 1; + env.storage().instance().set(&DataKey::ListingCount, &id); + id + } + + fn next_proposal_id(env: &Env) -> u64 { + let mut id: u64 = env + .storage() + .instance() + .get(&DataKey::ProposalCount) + .unwrap_or(0); + id += 1; + env.storage().instance().set(&DataKey::ProposalCount, &id); + id + } + + fn require_active_vault(env: &Env, vault_id: u64) -> Vault { + let vault: Vault = env + .storage() + .persistent() + .get(&DataKey::Vault(vault_id)) + .unwrap_or_else(|| panic!("vault_not_found")); + if !vault.active { + panic!("vault_inactive"); + } + vault + } + + fn balance(env: &Env, vault_id: u64, owner: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(vault_id, owner.clone())) + .unwrap_or(0) + } + + fn set_balance(env: &Env, vault_id: u64, owner: &Address, amount: i128) { + env.storage() + .persistent() + .set(&DataKey::Balance(vault_id, owner.clone()), &amount); + } + + fn set_reward_debt(env: &Env, vault_id: u64, owner: &Address, amount: i128) { + env.storage() + .persistent() + .set(&DataKey::RewardDebt(vault_id, owner.clone()), &amount); + } + + fn before_balance_change(env: &Env, vault_id: u64, owner: &Address) { + if let Some(vault) = env.storage().persistent().get::(&DataKey::Vault(vault_id)) { + if !vault.active { + return; + } + let bal = Self::balance(env, vault_id, owner); + let debt = env + .storage() + .persistent() + .get(&DataKey::RewardDebt(vault_id, owner.clone())) + .unwrap_or(0); + + let accumulated = bal + .checked_mul(vault.acc_rental_per_share) + .unwrap_or_else(|| panic!("overflow")) + / ACC_SCALE; + + let pending = accumulated - debt; + if pending > 0 { + let key = DataKey::Claimable(vault_id, owner.clone()); + let current: i128 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(current + pending)); + } + } + } + + fn after_balance_change(env: &Env, vault_id: u64, owner: &Address) { + if let Some(vault) = env.storage().persistent().get::(&DataKey::Vault(vault_id)) { + if !vault.active { + return; + } + let bal = Self::balance(env, vault_id, owner); + let new_debt = bal + .checked_mul(vault.acc_rental_per_share) + .unwrap_or_else(|| panic!("overflow")) + / ACC_SCALE; + Self::set_reward_debt(env, vault_id, owner, new_debt); + } + } + + fn transfer_shares_internal(env: &Env, vault_id: u64, from: &Address, to: &Address, amount: i128) { + if amount <= 0 { + panic!("invalid_amount"); + } + + Self::before_balance_change(env, vault_id, from); + Self::before_balance_change(env, vault_id, to); + + let from_bal = Self::balance(env, vault_id, from); + if from_bal < amount { + panic!("insufficient_balance"); + } + let to_bal = Self::balance(env, vault_id, to); + + Self::set_balance(env, vault_id, from, from_bal - amount); + Self::set_balance(env, vault_id, to, to_bal + amount); + + Self::enforce_min_threshold(env, vault_id, from); + Self::enforce_min_threshold(env, vault_id, to); + + Self::after_balance_change(env, vault_id, from); + Self::after_balance_change(env, vault_id, to); + } + + fn enforce_min_threshold(env: &Env, vault_id: u64, owner: &Address) { + let vault: Vault = env + .storage() + .persistent() + .get(&DataKey::Vault(vault_id)) + .unwrap_or_else(|| panic!("vault_not_found")); + + if vault.min_ownership_bps == 0 { + return; + } + + let bal = Self::balance(env, vault_id, owner); + if bal == 0 { + return; + } + + let min_shares = (vault.total_shares + .checked_mul(vault.min_ownership_bps as i128) + .unwrap_or_else(|| panic!("overflow")) + + (BPS_DENOMINATOR - 1)) + / BPS_DENOMINATOR; + + if bal < min_shares { + panic!("below_min_ownership_threshold"); + } + } +} + +mod test; diff --git a/contracts/fractional_nft/src/test.rs b/contracts/fractional_nft/src/test.rs new file mode 100644 index 0000000..9eb8533 --- /dev/null +++ b/contracts/fractional_nft/src/test.rs @@ -0,0 +1,358 @@ +#![cfg(test)] + +use super::*; +use soroban_sdk::{contract, contractimpl, testutils::{Address as _, Ledger}, token, Address, Env, String}; + +// Mock NFT contract for testing fractional_nft +#[contract] +pub struct MockNFT; + +#[contractimpl] +impl MockNFT { + pub fn owner_of(env: Env, token_id: u32) -> Address { + env.storage() + .persistent() + .get(&token_id) + .unwrap_or_else(|| panic!("token_not_found")) + } + + pub fn transfer(env: Env, from: Address, to: Address, token_id: u32) { + from.require_auth(); + let current_owner: Address = env.storage() + .persistent() + .get(&token_id) + .unwrap_or_else(|| panic!("token_not_found")); + if current_owner != from { + panic!("not_owner"); + } + env.storage().persistent().set(&token_id, &to); + } + + pub fn set_owner(env: Env, token_id: u32, owner: Address) { + env.storage().persistent().set(&token_id, &owner); + } +} + +fn setup_token<'a>( + env: &'a Env, + admin: &'a Address, +) -> (Address, token::Client<'a>, token::StellarAssetClient<'a>) { + let token_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_client = token::Client::new(env, &token_id); + let token_admin_client = token::StellarAssetClient::new(env, &token_id); + (token_id, token_client, token_admin_client) +} + +fn setup_mock_nft<'a>(env: &'a Env) -> (Address, MockNFTClient<'a>) { + let nft_id = env.register_contract(None, MockNFT); + let client = MockNFTClient::new(env, &nft_id); + (nft_id, client) +} + +#[test] +fn test_fractionalize_and_transfer_shares() { + let env = Env::default(); + env.mock_all_auths(); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &owner, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &None, + ); + + assert_eq!(client.get_vault(&vault_id).unwrap().total_shares, 100); + assert_eq!(client.balance_of(&vault_id, &owner), 100); + + let alice = Address::generate(&env); + client.transfer_shares(&vault_id, &owner, &alice, &25i128); + + assert_eq!(client.balance_of(&vault_id, &owner), 75); + assert_eq!(client.balance_of(&vault_id, &alice), 25); +} + +#[test] +#[should_panic(expected = "below_min_ownership_threshold")] +fn test_minimum_ownership_threshold_enforced() { + let env = Env::default(); + env.mock_all_auths(); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + // total_shares=100, min_ownership_bps=1000 => min_shares=10 + let vault_id = client.fractionalize( + &owner, + &nft_contract_id, + &token_id, + &100i128, + &1000u32, + &None, + ); + + let bob = Address::generate(&env); + // This would leave bob with 5 shares (below 10) => should panic. + client.transfer_shares(&vault_id, &owner, &bob, &5i128); +} + +#[test] +fn test_share_trading_listing_buy_and_cancel() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let (payment_token_id, payment_token, payment_admin) = setup_token(&env, &admin); + payment_admin.mint(&buyer, &1_000); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &seller); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &seller, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &None, + ); + + let listing_id = client.create_listing( + &seller, + &vault_id, + &20i128, + &payment_token_id, + &2i128, + &None, + ); + + assert_eq!(client.balance_of(&vault_id, &seller), 80); + assert_eq!(client.balance_of(&vault_id, &contract_id), 20); + + client.buy_listing(&buyer, &listing_id); + + assert_eq!(client.balance_of(&vault_id, &buyer), 20); + assert_eq!(client.balance_of(&vault_id, &contract_id), 0); + + // Buyer paid seller 40 tokens. + assert_eq!(payment_token.balance(&buyer), 960); + assert_eq!(payment_token.balance(&seller), 40); + + // Create another listing and cancel it. + let listing_id2 = client.create_listing( + &seller, + &vault_id, + &10i128, + &payment_token_id, + &1i128, + &None, + ); + assert_eq!(client.balance_of(&vault_id, &contract_id), 10); + + client.cancel_listing(&seller, &listing_id2); + assert_eq!(client.balance_of(&vault_id, &contract_id), 0); + assert_eq!(client.balance_of(&vault_id, &seller), 80); +} + +#[test] +fn test_buyout_tender_and_reclaim() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let buyer = Address::generate(&env); + let holder = Address::generate(&env); + + let (payment_token_id, payment_token, payment_admin) = setup_token(&env, &admin); + payment_admin.mint(&buyer, &10_000); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + + let original_owner = Address::generate(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &original_owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &original_owner, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &None, + ); + + // Move some shares to holder. + client.transfer_shares(&vault_id, &original_owner, &holder, &30i128); + + env.ledger().set_timestamp(100); + client.start_buyout(&buyer, &vault_id, &payment_token_id, &1_000i128, &200u64); + + // Holder tenders 10 shares => gets 1000 * 10 / 100 = 100. + client.tender_shares(&holder, &vault_id, &10i128); + assert_eq!(payment_token.balance(&holder), 100); + assert_eq!(client.balance_of(&vault_id, &buyer), 10); + + // After end_time, buyer can reclaim remaining escrow. + env.ledger().set_timestamp(201); + let buyer_before = payment_token.balance(&buyer); + client.reclaim_buyout_escrow(&buyer, &vault_id); + let buyer_after = payment_token.balance(&buyer); + assert!(buyer_after > buyer_before); +} + +#[test] +fn test_profit_sharing_from_rentals_and_claim() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let payer = Address::generate(&env); + let owner = Address::generate(&env); + let other = Address::generate(&env); + + let (rental_token_id, rental_token, rental_admin) = setup_token(&env, &admin); + rental_admin.mint(&payer, &1_000); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &owner, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &Some(rental_token_id.clone()), + ); + + // Transfer 40 shares to other. + client.transfer_shares(&vault_id, &owner, &other, &40i128); + + client.deposit_rental_income(&payer, &vault_id, &500i128); + + // Owner has 60%, other has 40%. + client.claim_rental_profit(&owner, &vault_id); + client.claim_rental_profit(&other, &vault_id); + + assert_eq!(rental_token.balance(&owner), 300); + assert_eq!(rental_token.balance(&other), 200); +} + +#[test] +fn test_voting_set_rental_token() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let voter2 = Address::generate(&env); + + let (new_rental_token_id, _, _) = setup_token(&env, &admin); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &owner, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &None, + ); + + client.transfer_shares(&vault_id, &owner, &voter2, &30i128); + + env.ledger().set_timestamp(100); + let proposal_id = client.create_proposal_set_rental_token( + &owner, + &vault_id, + &new_rental_token_id, + &50u64, + ); + + client.vote(&owner, &proposal_id, &true); + client.vote(&voter2, &proposal_id, &true); + + env.ledger().set_timestamp(151); + client.execute_proposal(&proposal_id); + + assert_eq!(client.get_vault(&vault_id).unwrap().rental_token, Some(new_rental_token_id)); +} + +#[test] +fn test_recombine_merge_fractions() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let owner = Address::generate(&env); + let receiver = Address::generate(&env); + + let (nft_contract_id, nft) = setup_mock_nft(&env); + let token_id = 1u32; + nft.set_owner(&token_id, &owner); + + let contract_id = env.register_contract(None, FractionalNftContract); + let client = FractionalNftContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let vault_id = client.fractionalize( + &owner, + &nft_contract_id, + &token_id, + &100i128, + &0u32, + &None, + ); + + // Owner already holds 100 shares. + client.recombine(&owner, &vault_id, &receiver); + + assert_eq!(nft.owner_of(&token_id), receiver); + assert_eq!(client.get_vault(&vault_id).unwrap().active, false); +}