diff --git a/Cargo.toml b/Cargo.toml
index 39f0556..5e445cc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
"contracts/subscription",
"contracts/achievement_collection",
"contracts/achievement_nft",
+ "contracts/fractional_nft",
"contracts/achievement_sets",
"contracts/auction",
"contracts/battle_pass",
diff --git a/contracts/achievement_nft/src/lib.rs b/contracts/achievement_nft/src/lib.rs
index 020949d..76d9ecb 100644
--- a/contracts/achievement_nft/src/lib.rs
+++ b/contracts/achievement_nft/src/lib.rs
@@ -1,5 +1,8 @@
#![no_std]
+use soroban_sdk::{
+ contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec,
+};
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, Address, Env, String, Vec};
#[contracttype]
@@ -36,15 +39,21 @@ impl AchievementNFT {
env.storage().instance().set(&DataKey::TotalSupply, &0u32);
}
+ /// Mark a puzzle as completed by a user.
/// Admin function to mark a puzzle as completed for 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(&DataKey::PuzzleCompleted(user, puzzle_id), &true);
+ .set(&key, &true);
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, 100_000, 500_000);
}
+ /// Mint a new achievement NFT
/// Mint a new achievement NFT (requires that the puzzle was previously marked completed).
pub fn mint(env: Env, to: Address, puzzle_id: u32, metadata: String) -> u32 {
to.require_auth();
@@ -59,6 +68,7 @@ impl AchievementNFT {
panic!("Puzzle not completed");
}
+ Self::craftmint(env, to, puzzle_id, metadata)
Self::mint_internal(env, to, puzzle_id, metadata)
}
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);
+}