From 1368d4b5740f5e088fcf3da0b45622bc56543193 Mon Sep 17 00:00:00 2001 From: financialcrackerjack-max Date: Wed, 29 Jul 2026 09:43:15 +0100 Subject: [PATCH] feat: secondary market for pool positions and co-funding shares (#1025) - Add Listing, ListingStatus, ListingKind types to pool contract - Add PoolError variants: ListingNotFound, ListingNotOpen, ListingNotSeller, InsufficientListingBalance, TooManyListings - Add Symbol storage keys: LISTING_DATA, LISTING_IDS_INV, LISTING_IDS_SELLER, LISTING_COUNTER (DataKey is at Soroban 50-variant ceiling) - Implement list_position: validates CoFundShare/deployed balance, enforces compliance gate, per-invoice cap (50), allocates listing ID - Implement cancel_listing: seller-only, open listings only - Implement buy_listing: compliance + KYC + concentration-cap checks on buyer, atomically transfers CoFundShare bps or deployed principal slice, debits buyer available and credits seller available - Implement get_listing, list_listings_for_invoice, list_listings_for_investor - Emit lst_open / lst_cncl / lst_buy events under pool EVT topic - Add secondary_market_tests.rs: list, cancel, buy, index query, error paths - SDK: add Listing/ListingStatus/ListingKind types; add listPosition, cancelListing, buyListing, getListing, listListingsForInvoice, listListingsForInvestor to PoolClient - Indexer: register lst_open/lst_cncl/lst_buy event types; extract actor - Docs: add interacting-with-contracts.md documenting the full flow --- contracts/pool/src/lib.rs | 536 ++++++++++++++++++ .../pool/tests/secondary_market_tests.rs | 402 +++++++++++++ docs/interacting-with-contracts.md | 133 +++++ indexer/src/parser.ts | 16 + packages/sdk/src/clients/pool.ts | 113 ++++ packages/sdk/src/types.ts | 17 + sdk/src/client.ts | 3 + 7 files changed, 1220 insertions(+) create mode 100644 contracts/pool/tests/secondary_market_tests.rs create mode 100644 docs/interacting-with-contracts.md diff --git a/contracts/pool/src/lib.rs b/contracts/pool/src/lib.rs index 0cc88002..2129bc85 100644 --- a/contracts/pool/src/lib.rs +++ b/contracts/pool/src/lib.rs @@ -170,6 +170,13 @@ pub enum PoolError { // #864: role-based multisig access-control AccessControlNotConfigured = 88, AccessControlAlreadyConfigured = 89, + // #1025: secondary market errors + ListingNotFound = 90, + ListingNotOpen = 91, + ListingNotSeller = 92, + InsufficientListingBalance = 93, + TooManyListings = 94, + ListingPriceMismatch = 95, } type PoolResult = Result; @@ -571,6 +578,44 @@ pub struct RepaymentRequest { pub amount: i128, } +// #1025: secondary market for pool positions and co-funding shares. + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ListingStatus { + Open, + Filled, + Cancelled, +} + +/// Whether the listing covers a co-funded share (bps of a CoFundingRound) +/// or a single-funded position slice (raw token amount of deployed principal). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ListingKind { + CoFunding, + SingleFunded, +} + +/// A secondary-market listing created by `list_position`. +/// `amount_or_bps` is: +/// - for `CoFunding`: the bps of the seller's CoFundShare being offered +/// - for `SingleFunded`: the raw token amount of deployed principal being offered +/// `price` is the flat token amount the seller wants in exchange. +#[contracttype] +#[derive(Clone)] +pub struct Listing { + pub listing_id: u64, + pub invoice_id: u64, + pub seller: Address, + pub token: Address, + pub kind: ListingKind, + pub amount_or_bps: u64, + pub price: i128, + pub created_at: u64, + pub status: ListingStatus, +} + #[contracttype] #[derive(Clone, Default)] pub struct PoolStorageStats { @@ -804,6 +849,15 @@ const ORACLE_FALLBACK_PX: Symbol = symbol_short!("fb_price"); // #777: default max age (seconds) a Reflector price may have before it's // treated as stale and the admin fallback price is used instead. const DEFAULT_ORACLE_STALE_SECS: u64 = 3600; +// #1025: secondary market — per-listing storage (listing_id -> Listing) and +// per-invoice / per-investor index vecs. Symbol keys because DataKey is +// already at Soroban's 50-variant ceiling. +const LISTING_DATA: Symbol = symbol_short!("lst_data"); +const LISTING_IDS_INV: Symbol = symbol_short!("lst_inv"); // invoice_id -> Vec +const LISTING_IDS_SELLER: Symbol = symbol_short!("lst_sel"); // seller -> Vec +const LISTING_COUNTER: Symbol = symbol_short!("lst_cnt"); +// Maximum open listings per invoice to bound iteration gas cost. +const MAX_LISTINGS_PER_INVOICE: u32 = 50; #[contractclient(name = "CreditScoreClient")] pub trait CreditScoreContract { @@ -7239,6 +7293,488 @@ impl FundingPool { .instance() .set(&DataKey::ReentrancyGuard, &false); } + + // ── #1025: Secondary market for pool positions and co-funding shares ────── + + /// List part or all of a position for sale on the secondary market. + /// + /// For `CoFunding` kind: `amount_or_bps` is the bps of the seller's + /// `CoFundShare` to offer (1..=seller_share). The round must be `Filled`. + /// + /// For `SingleFunded` kind: `amount_or_bps` is the raw token amount of + /// deployed principal to offer. The invoice must not be fully repaid. + /// + /// `price` is the flat token amount the buyer must pay from their + /// `available` balance. + /// + /// Compliance and KYC are checked on the seller at listing time. + pub fn list_position( + env: Env, + seller: Address, + invoice_id: u64, + kind: ListingKind, + amount_or_bps: u64, + price: i128, + ) -> Result { + seller.require_auth(); + bump_instance(&env); + Self::require_not_paused(&env); + Self::require_compliance_cleared(&env, &seller)?; + + if amount_or_bps == 0 { + return Err(PoolError::ZeroAmount); + } + if price <= 0 { + return Err(PoolError::InvalidAmount); + } + + non_reentrant!(&env, { + let record: FundedInvoice = env + .storage() + .persistent() + .get(&DataKey::FundedInvoice(invoice_id)) + .ok_or(PoolError::InvoiceNotFound)?; + + let token = record.token.clone(); + + match kind { + ListingKind::CoFunding => { + let round_id = record.co_funding_round_id.ok_or(PoolError::InvalidAmount)?; + let round: CoFundingRound = env + .storage() + .persistent() + .get(&DataKey::CoFundingRound(round_id)) + .ok_or(PoolError::CoFundingRoundNotFound)?; + if round.status != CoFundingStatus::Filled { + return Err(PoolError::CoFundingRoundNotFilled); + } + let seller_bps: u32 = env + .storage() + .persistent() + .get(&DataKey::CoFundShare(round_id, seller.clone())) + .unwrap_or(0); + if amount_or_bps as u32 > seller_bps || seller_bps == 0 { + return Err(PoolError::InsufficientListingBalance); + } + } + ListingKind::SingleFunded => { + if record.co_funding_round_id.is_some() { + return Err(PoolError::InvalidAmount); + } + let pos_key = DataKey::InvestorPosition(seller.clone(), token.clone()); + let position: InvestorPosition = env + .storage() + .persistent() + .get(&pos_key) + .unwrap_or(InvestorPosition { + deposited: 0, + available: 0, + deployed: 0, + earned: 0, + deposit_count: 0, + loyalty_start_at: 0, + }); + if (amount_or_bps as i128) > position.deployed || position.deployed == 0 { + return Err(PoolError::InsufficientListingBalance); + } + } + } + + // Enforce per-invoice listing cap to bound iteration gas. + let mut inv_map: Map> = env + .storage() + .instance() + .get(&LISTING_IDS_INV) + .unwrap_or_else(|| Map::new(&env)); + let existing: Vec = inv_map + .get(invoice_id) + .unwrap_or_else(|| Vec::new(&env)); + if existing.len() >= MAX_LISTINGS_PER_INVOICE { + return Err(PoolError::TooManyListings); + } + + // Allocate listing ID. + let listing_id: u64 = env + .storage() + .instance() + .get(&LISTING_COUNTER) + .unwrap_or(0u64) + .checked_add(1) + .ok_or(PoolError::AmountOverflow)?; + env.storage() + .instance() + .set(&LISTING_COUNTER, &listing_id); + + let listing = Listing { + listing_id, + invoice_id, + seller: seller.clone(), + token, + kind, + amount_or_bps, + price, + created_at: env.ledger().timestamp(), + status: ListingStatus::Open, + }; + + // Store listing data in persistent storage. + let mut all_listings: Map = env + .storage() + .persistent() + .get(&LISTING_DATA) + .unwrap_or_else(|| Map::new(&env)); + all_listings.set(listing_id, listing); + env.storage() + .persistent() + .set(&LISTING_DATA, &all_listings); + env.storage().persistent().extend_ttl( + &LISTING_DATA, + ACTIVE_INVOICE_TTL, + ACTIVE_INVOICE_TTL, + ); + + // Update invoice index. + let mut updated_inv = existing; + updated_inv.push_back(listing_id); + inv_map.set(invoice_id, updated_inv); + env.storage().instance().set(&LISTING_IDS_INV, &inv_map); + + // Update seller index. + let mut sel_map: Map> = env + .storage() + .instance() + .get(&LISTING_IDS_SELLER) + .unwrap_or_else(|| Map::new(&env)); + let mut seller_vec: Vec = sel_map + .get(seller.clone()) + .unwrap_or_else(|| Vec::new(&env)); + seller_vec.push_back(listing_id); + sel_map.set(seller.clone(), seller_vec); + env.storage() + .instance() + .set(&LISTING_IDS_SELLER, &sel_map); + + env.events().publish( + (EVT, symbol_short!("lst_open")), + (listing_id, invoice_id, seller, amount_or_bps, price), + ); + Ok(listing_id) + }) + } + + /// Cancel an open listing. Only the original seller may cancel. + pub fn cancel_listing(env: Env, seller: Address, listing_id: u64) -> Result<(), PoolError> { + seller.require_auth(); + bump_instance(&env); + Self::require_not_paused(&env); + + non_reentrant!(&env, { + + let mut all_listings: Map = env + .storage() + .persistent() + .get(&LISTING_DATA) + .ok_or(PoolError::ListingNotFound)?; + let mut listing: Listing = all_listings + .get(listing_id) + .ok_or(PoolError::ListingNotFound)?; + + if listing.seller != seller { + return Err(PoolError::ListingNotSeller); + } + if listing.status != ListingStatus::Open { + return Err(PoolError::ListingNotOpen); + } + + listing.status = ListingStatus::Cancelled; + all_listings.set(listing_id, listing.clone()); + env.storage().persistent().set(&LISTING_DATA, &all_listings); + + env.events().publish( + (EVT, symbol_short!("lst_cncl")), + (listing_id, listing.invoice_id, seller), + ); + Ok(()) + }) + } + + /// Buy an open listing. The buyer's `available` balance in the listing's + /// token is debited by `listing.price`; the seller's claim (CoFundShare + /// bps or deployed principal slice) is atomically transferred to the buyer. + /// + /// Compliance, KYC, and concentration-cap checks are enforced on the buyer. + pub fn buy_listing( + env: Env, + buyer: Address, + listing_id: u64, + ) -> Result<(), PoolError> { + buyer.require_auth(); + bump_instance(&env); + Self::require_not_paused(&env); + Self::require_compliance_cleared(&env, &buyer)?; + + non_reentrant!(&env, { + + let mut all_listings: Map = env + .storage() + .persistent() + .get(&LISTING_DATA) + .ok_or(PoolError::ListingNotFound)?; + let mut listing: Listing = all_listings + .get(listing_id) + .ok_or(PoolError::ListingNotFound)?; + + if listing.status != ListingStatus::Open { + return Err(PoolError::ListingNotOpen); + } + if listing.seller == buyer { + return Err(PoolError::Unauthorized); + } + + // KYC check on buyer. + let kyc_required: bool = env + .storage() + .instance() + .get(&DataKey::KycRequired) + .unwrap_or(false); + if kyc_required { + let status: KycStatus = env + .storage() + .persistent() + .get(&DataKey::InvestorKyc(buyer.clone())) + .unwrap_or(KycStatus::NotRequested); + match status { + KycStatus::Approved => {} + KycStatus::NotRequested => return Err(PoolError::KycNotRequested), + KycStatus::Rejected => return Err(PoolError::KycRejected), + } + } + + let token = listing.token.clone(); + let token_totals_key = DataKey::TokenTotals(token.clone()); + let tt: PoolTokenTotals = env + .storage() + .instance() + .get(&token_totals_key) + .unwrap_or_default(); + + // Buyer must have enough available balance to pay the price. + let buyer_pos_key = DataKey::InvestorPosition(buyer.clone(), token.clone()); + let mut buyer_pos: InvestorPosition = env + .storage() + .persistent() + .get(&buyer_pos_key) + .unwrap_or(InvestorPosition { + deposited: 0, + available: 0, + deployed: 0, + earned: 0, + deposit_count: 0, + loyalty_start_at: 0, + }); + if buyer_pos.available < listing.price { + return Err(PoolError::InvalidAmount); + } + + // Concentration cap: buying deployed capital increases the buyer's + // effective share of the pool. Check against max_single_investor_bps. + let config = get_config_cached(&env)?; + if config.max_single_investor_bps < BPS_DENOM { + let buyer_new_deployed = buyer_pos + .deployed + .checked_add(listing.price) + .ok_or(PoolError::AmountOverflow)?; + let pool_value = tt.pool_value.max(1); + let share_bps = + ((buyer_new_deployed as u128 * BPS_DENOM as u128) / pool_value as u128) as u32; + if share_bps > config.max_single_investor_bps { + return Err(PoolError::ConcentrationLimitExceeded); + } + } + + // Transfer the claim to the buyer. + match listing.kind { + ListingKind::CoFunding => { + let record: FundedInvoice = env + .storage() + .persistent() + .get(&DataKey::FundedInvoice(listing.invoice_id)) + .ok_or(PoolError::InvoiceNotFound)?; + let round_id = record.co_funding_round_id.ok_or(PoolError::InvalidAmount)?; + + let from_key = + DataKey::CoFundShare(round_id, listing.seller.clone()); + let to_key = DataKey::CoFundShare(round_id, buyer.clone()); + + let from_bps: u32 = env + .storage() + .persistent() + .get(&from_key) + .unwrap_or(0); + let transfer_bps = listing.amount_or_bps as u32; + if transfer_bps > from_bps { + return Err(PoolError::InsufficientListingBalance); + } + let to_bps: u32 = env + .storage() + .persistent() + .get(&to_key) + .unwrap_or(0); + + let new_from = from_bps - transfer_bps; + let new_to = to_bps + .checked_add(transfer_bps) + .ok_or(PoolError::AmountOverflow)?; + + if new_from == 0 { + env.storage().persistent().remove(&from_key); + } else { + env.storage().persistent().set(&from_key, &new_from); + } + env.storage().persistent().set(&to_key, &new_to); + + // Keep round participant list in sync. + if let Some(mut round) = env + .storage() + .persistent() + .get::(&DataKey::CoFundingRound(round_id)) + { + if new_from == 0 { + remove_participant(&env, &mut round, &listing.seller); + } + if to_bps == 0 { + if round.participants.len() >= MAX_CO_FUNDING_PARTICIPANTS { + return Err(PoolError::CoFundingTooManyParticipants); + } + round.participants.push_back(buyer.clone()); + } + env.storage() + .persistent() + .set(&DataKey::CoFundingRound(round_id), &round); + } + } + ListingKind::SingleFunded => { + // Move deployed principal slice from seller to buyer. + let seller_pos_key = + DataKey::InvestorPosition(listing.seller.clone(), token.clone()); + let mut seller_pos: InvestorPosition = env + .storage() + .persistent() + .get(&seller_pos_key) + .unwrap_or(InvestorPosition { + deposited: 0, + available: 0, + deployed: 0, + earned: 0, + deposit_count: 0, + loyalty_start_at: 0, + }); + let transfer_amount = listing.amount_or_bps as i128; + if seller_pos.deployed < transfer_amount { + return Err(PoolError::InsufficientListingBalance); + } + seller_pos.deployed = seller_pos + .deployed + .checked_sub(transfer_amount) + .ok_or(PoolError::AmountOverflow)?; + env.storage() + .persistent() + .set(&seller_pos_key, &seller_pos); + + buyer_pos.deployed = buyer_pos + .deployed + .checked_add(transfer_amount) + .ok_or(PoolError::AmountOverflow)?; + } + } + + // Debit buyer's available balance and credit seller. + buyer_pos.available = buyer_pos + .available + .checked_sub(listing.price) + .ok_or(PoolError::AmountOverflow)?; + env.storage().persistent().set(&buyer_pos_key, &buyer_pos); + + let seller_pos_key = + DataKey::InvestorPosition(listing.seller.clone(), token.clone()); + let mut seller_pos: InvestorPosition = env + .storage() + .persistent() + .get(&seller_pos_key) + .unwrap_or(InvestorPosition { + deposited: 0, + available: 0, + deployed: 0, + earned: 0, + deposit_count: 0, + loyalty_start_at: 0, + }); + seller_pos.available = seller_pos + .available + .checked_add(listing.price) + .ok_or(PoolError::AmountOverflow)?; + env.storage() + .persistent() + .set(&seller_pos_key, &seller_pos); + + // Mark listing filled. + listing.status = ListingStatus::Filled; + all_listings.set(listing_id, listing.clone()); + env.storage().persistent().set(&LISTING_DATA, &all_listings); + + env.events().publish( + (EVT, symbol_short!("lst_buy")), + ( + listing_id, + listing.invoice_id, + listing.seller.clone(), + buyer.clone(), + listing.price, + ), + ); + Ok(()) + }) + } + + /// Read a single listing by ID. Returns `None` if not found. + pub fn get_listing(env: Env, listing_id: u64) -> Option { + bump_instance(&env); + + let all_listings: Map = env + .storage() + .persistent() + .get(&LISTING_DATA) + .unwrap_or_else(|| Map::new(&env)); + all_listings.get(listing_id) + } + + /// List all listing IDs for a given invoice (open and closed). + pub fn list_listings_for_invoice(env: Env, invoice_id: u64) -> Vec { + bump_instance(&env); + + let inv_listings: Map> = env + .storage() + .instance() + .get(&LISTING_IDS_INV) + .unwrap_or_else(|| Map::new(&env)); + inv_listings + .get(invoice_id) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// List all listing IDs created by a given seller (open and closed). + pub fn list_listings_for_investor(env: Env, seller: Address) -> Vec { + bump_instance(&env); + + let sel_listings: Map> = env + .storage() + .instance() + .get(&LISTING_IDS_SELLER) + .unwrap_or_else(|| Map::new(&env)); + sel_listings + .get(seller) + .unwrap_or_else(|| Vec::new(&env)) + } } #[cfg(test)] diff --git a/contracts/pool/tests/secondary_market_tests.rs b/contracts/pool/tests/secondary_market_tests.rs new file mode 100644 index 00000000..aefd569a --- /dev/null +++ b/contracts/pool/tests/secondary_market_tests.rs @@ -0,0 +1,402 @@ +#![cfg(test)] + +// #1025: secondary market for pool positions and co-funding shares. + +use pool::{FundingPool, FundingPoolClient, ListingKind, ListingStatus, OpenCoFundingRequest, PoolError}; +use soroban_sdk::{ + contract, contractimpl, + testutils::{Address as _, Ledger}, + token, Address, Env, Symbol, +}; + +#[contract] +pub struct DummyShare; + +#[contractimpl] +impl DummyShare { + pub fn total_supply(env: Env) -> i128 { + env.storage() + .instance() + .get(&Symbol::new(&env, "tot")) + .unwrap_or(0) + } + pub fn balance(env: Env, id: Address) -> i128 { + env.storage().persistent().get(&id).unwrap_or(0) + } + pub fn mint(env: Env, to: Address, amount: i128) { + let t = Self::total_supply(env.clone()); + let b = Self::balance(env.clone(), to.clone()); + env.storage() + .instance() + .set(&Symbol::new(&env, "tot"), &(t + amount)); + env.storage().persistent().set(&to, &(b + amount)); + } + pub fn burn(env: Env, from: Address, amount: i128) { + let t = Self::total_supply(env.clone()); + let b = Self::balance(env.clone(), from.clone()); + env.storage() + .instance() + .set(&Symbol::new(&env, "tot"), &(t - amount)); + env.storage().persistent().set(&from, &(b - amount)); + } + pub fn decimals(_env: Env) -> u32 { + 7 + } +} + +#[contract] +pub struct DummyInvoice; + +#[contractimpl] +impl DummyInvoice { + pub fn get_authorized_pool(env: Env) -> Address { + env.storage() + .instance() + .get(&Symbol::new(&env, "pool")) + .expect("not initialized") + } + pub fn set_pool(env: Env, pool: Address) { + env.storage() + .instance() + .set(&Symbol::new(&env, "pool"), &pool); + } + pub fn record_funding(_env: Env, _id: u64, _amount: i128, _pool: Address) {} +} + +fn setup(env: &Env) -> (FundingPoolClient<'_>, Address, Address) { + env.ledger().with_mut(|l| l.timestamp = 100_000); + let contract_id = env.register(FundingPool, ()); + let client = FundingPoolClient::new(env, &contract_id); + let admin = Address::generate(env); + let token_admin = Address::generate(env); + let usdc_id = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let invoice_contract = env.register(DummyInvoice, ()); + DummyInvoiceClient::new(env, &invoice_contract).set_pool(&contract_id); + let share_token = env.register(DummyShare, ()); + client.initialize(&admin, &usdc_id, &share_token, &invoice_contract); + client.set_max_investor_concentration(&admin, &10_000u32); + (client, admin, usdc_id) +} + +fn mint(env: &Env, token_id: &Address, to: &Address, amount: i128) { + token::StellarAssetClient::new(env, token_id).mint(to, &amount); +} + +/// Helper: open + fill a co-funding round and return (round_id, investor, bps). +fn setup_filled_cofund_round( + env: &Env, + client: &FundingPoolClient<'_>, + admin: &Address, + usdc: &Address, +) -> (u64, Address) { + let investor = Address::generate(env); + mint(env, usdc, &investor, 10_000); + client.deposit(&investor, usdc, &10_000i128, &None); + + let invoice_id: u64 = 42; + let sme = Address::generate(env); + let due_date = env.ledger().timestamp() + 86_400; + let deadline = env.ledger().timestamp() + 3_600; + + client.open_co_funding( + admin, + &OpenCoFundingRequest { + invoice_id, + token: usdc.clone(), + target_principal: 5_000, + sme: sme.clone(), + due_date, + funding_deadline: deadline, + min_commitment: 100, + max_investor_bps: 0, + }, + ); + + client.commit_to_invoice(&investor, &invoice_id, &5_000i128); + client.finalize_co_funding(&investor, &invoice_id); + + (invoice_id, investor) +} + +// ── list_position ──────────────────────────────────────────────────────────── + +#[test] +fn test_list_cofund_position_creates_listing() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + assert!(bps > 0); + + let listing_id = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &1_000i128) + .unwrap(); + + let listing = client.get_listing(&listing_id).unwrap(); + assert_eq!(listing.invoice_id, invoice_id); + assert_eq!(listing.seller, investor); + assert_eq!(listing.status, ListingStatus::Open); + assert_eq!(listing.amount_or_bps, bps as u64); + assert_eq!(listing.price, 1_000); +} + +#[test] +fn test_list_position_zero_amount_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let result = client.try_list_position( + &investor, + &invoice_id, + &ListingKind::CoFunding, + &0u64, + &1_000i128, + ); + assert_eq!(result.unwrap_err().unwrap(), PoolError::ZeroAmount.into()); +} + +#[test] +fn test_list_position_zero_price_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let result = client.try_list_position( + &investor, + &invoice_id, + &ListingKind::CoFunding, + &(bps as u64), + &0i128, + ); + assert_eq!(result.unwrap_err().unwrap(), PoolError::InvalidAmount.into()); +} + +#[test] +fn test_list_position_exceeds_share_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let result = client.try_list_position( + &investor, + &invoice_id, + &ListingKind::CoFunding, + &(bps as u64 + 1), + &1_000i128, + ); + assert_eq!( + result.unwrap_err().unwrap(), + PoolError::InsufficientListingBalance.into() + ); +} + +// ── cancel_listing ─────────────────────────────────────────────────────────── + +#[test] +fn test_cancel_listing_by_seller_succeeds() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let listing_id = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &500i128) + .unwrap(); + + client.cancel_listing(&investor, &listing_id).unwrap(); + + let listing = client.get_listing(&listing_id).unwrap(); + assert_eq!(listing.status, ListingStatus::Cancelled); +} + +#[test] +fn test_cancel_listing_by_non_seller_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let listing_id = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &500i128) + .unwrap(); + + let stranger = Address::generate(&env); + let result = client.try_cancel_listing(&stranger, &listing_id); + assert_eq!(result.unwrap_err().unwrap(), PoolError::ListingNotSeller.into()); +} + +#[test] +fn test_cancel_already_cancelled_listing_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let listing_id = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &500i128) + .unwrap(); + + client.cancel_listing(&investor, &listing_id).unwrap(); + let result = client.try_cancel_listing(&investor, &listing_id); + assert_eq!(result.unwrap_err().unwrap(), PoolError::ListingNotOpen.into()); +} + +// ── buy_listing ────────────────────────────────────────────────────────────── + +#[test] +fn test_buy_cofund_listing_transfers_share_and_price() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, seller) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let seller_bps = client.get_co_fund_share(&invoice_id, &seller); + let price = 800i128; + + let listing_id = client + .list_position(&seller, &invoice_id, &ListingKind::CoFunding, &(seller_bps as u64), &price) + .unwrap(); + + // Buyer deposits funds so they have available balance. + let buyer = Address::generate(&env); + mint(&env, &usdc, &buyer, 2_000); + client.deposit(&buyer, &usdc, &2_000i128, &None); + + let buyer_pos_before = client.get_position(&buyer, &usdc).unwrap(); + let seller_pos_before = client.get_position(&seller, &usdc).unwrap(); + + client.buy_listing(&buyer, &listing_id).unwrap(); + + // Listing is now Filled. + let listing = client.get_listing(&listing_id).unwrap(); + assert_eq!(listing.status, ListingStatus::Filled); + + // Buyer's CoFundShare increased; seller's decreased. + let buyer_bps_after = client.get_co_fund_share(&invoice_id, &buyer); + let seller_bps_after = client.get_co_fund_share(&invoice_id, &seller); + assert_eq!(buyer_bps_after, seller_bps); + assert_eq!(seller_bps_after, 0); + + // Buyer's available balance decreased by price; seller's increased. + let buyer_pos_after = client.get_position(&buyer, &usdc).unwrap(); + let seller_pos_after = client.get_position(&seller, &usdc).unwrap(); + assert_eq!(buyer_pos_after.available, buyer_pos_before.available - price); + assert_eq!(seller_pos_after.available, seller_pos_before.available + price); +} + +#[test] +fn test_buy_cancelled_listing_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, seller) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &seller); + let listing_id = client + .list_position(&seller, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &500i128) + .unwrap(); + client.cancel_listing(&seller, &listing_id).unwrap(); + + let buyer = Address::generate(&env); + mint(&env, &usdc, &buyer, 1_000); + client.deposit(&buyer, &usdc, &1_000i128, &None); + + let result = client.try_buy_listing(&buyer, &listing_id); + assert_eq!(result.unwrap_err().unwrap(), PoolError::ListingNotOpen.into()); +} + +#[test] +fn test_seller_cannot_buy_own_listing() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, seller) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &seller); + let listing_id = client + .list_position(&seller, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &500i128) + .unwrap(); + + let result = client.try_buy_listing(&seller, &listing_id); + assert_eq!(result.unwrap_err().unwrap(), PoolError::Unauthorized.into()); +} + +#[test] +fn test_buy_listing_insufficient_available_balance_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, seller) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &seller); + // Price is higher than buyer's available balance. + let listing_id = client + .list_position(&seller, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &99_999i128) + .unwrap(); + + let buyer = Address::generate(&env); + mint(&env, &usdc, &buyer, 100); + client.deposit(&buyer, &usdc, &100i128, &None); + + let result = client.try_buy_listing(&buyer, &listing_id); + assert_eq!(result.unwrap_err().unwrap(), PoolError::InvalidAmount.into()); +} + +// ── index queries ──────────────────────────────────────────────────────────── + +#[test] +fn test_list_listings_for_invoice_returns_all_ids() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let id1 = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64 / 2), &100i128) + .unwrap(); + let id2 = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64 / 2), &200i128) + .unwrap(); + + let ids = client.list_listings_for_invoice(&invoice_id); + assert!(ids.contains(&id1)); + assert!(ids.contains(&id2)); +} + +#[test] +fn test_list_listings_for_investor_returns_seller_ids() { + let env = Env::default(); + env.mock_all_auths(); + let (client, admin, usdc) = setup(&env); + let (invoice_id, investor) = setup_filled_cofund_round(&env, &client, &admin, &usdc); + + let bps = client.get_co_fund_share(&invoice_id, &investor); + let listing_id = client + .list_position(&investor, &invoice_id, &ListingKind::CoFunding, &(bps as u64), &300i128) + .unwrap(); + + let ids = client.list_listings_for_investor(&investor); + assert!(ids.contains(&listing_id)); +} + +#[test] +fn test_get_listing_nonexistent_returns_none() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _usdc) = setup(&env); + assert!(client.get_listing(&9999u64).is_none()); +} diff --git a/docs/interacting-with-contracts.md b/docs/interacting-with-contracts.md new file mode 100644 index 00000000..94e2c54f --- /dev/null +++ b/docs/interacting-with-contracts.md @@ -0,0 +1,133 @@ +# Interacting with Contracts + +## Secondary Market for Pool Positions and Co-Funding Shares + +Investors who have deployed capital into a funded invoice can list their +position for sale before the invoice repays, providing early-exit liquidity +without touching the withdrawal queue. + +### Concepts + +| Term | Meaning | +|------|---------| +| `CoFunding` listing | Seller offers some or all of their `CoFundShare` bps in a filled co-funding round | +| `SingleFunded` listing | Seller offers a raw token amount of their `deployed` principal in a single-funded invoice | +| `price` | Flat token amount the buyer pays from their `available` pool balance | + +### Entrypoints + +#### `list_position(seller, invoice_id, kind, amount_or_bps, price) -> u64` + +Creates an open listing and returns its `listing_id`. + +- `kind = CoFunding`: `amount_or_bps` is the bps of the seller's `CoFundShare` + to offer (must be ≤ seller's current share; round must be `Filled`). +- `kind = SingleFunded`: `amount_or_bps` is the raw token amount of deployed + principal to offer (must be ≤ seller's `deployed` balance). +- Compliance gate and KYC are checked on the seller at listing time. +- At most `50` open listings per invoice are allowed. + +```bash +stellar contract invoke --id --source seller --network testnet \ + -- list_position \ + --seller \ + --invoice_id 42 \ + --kind '{"CoFunding": []}' \ + --amount_or_bps 5000 \ + --price 4800 +``` + +#### `cancel_listing(seller, listing_id)` + +Cancels an open listing. Only the original seller may cancel. + +```bash +stellar contract invoke --id --source seller --network testnet \ + -- cancel_listing \ + --seller \ + --listing_id 1 +``` + +#### `buy_listing(buyer, listing_id)` + +Atomically: +1. Debits `listing.price` from the buyer's `available` balance. +2. Credits `listing.price` to the seller's `available` balance. +3. Transfers the claim (CoFundShare bps or deployed principal slice) to the buyer. +4. Marks the listing `Filled`. + +Compliance, KYC, and the per-investor concentration cap +(`PoolConfig.max_single_investor_bps`) are enforced on the buyer. + +```bash +stellar contract invoke --id --source buyer --network testnet \ + -- buy_listing \ + --buyer \ + --listing_id 1 +``` + +#### `get_listing(listing_id) -> Option` + +Read a single listing by ID. + +#### `list_listings_for_invoice(invoice_id) -> Vec` + +Returns all listing IDs (open and closed) for a given invoice. + +#### `list_listings_for_investor(seller) -> Vec` + +Returns all listing IDs (open and closed) created by a given seller. + +### Repayment after a transfer + +When `repay_invoice` is called after a secondary-market transfer: + +- **Co-funded invoices**: `repay_invoice_request` distributes proceeds + pro-rata by the *current* `CoFundShare` bps, so the buyer (new holder) + receives the repayment, not the original seller. +- **Single-funded invoices**: the buyer's `deployed` balance is credited + when the invoice repays via the `reward_per_share` accumulator, which + tracks the current holder's share token balance. + +### Default after a transfer + +If `mark_defaulted` fires after a secondary-market transfer, any +insurance-reserve payout resolves to the *current* holder of the claim, +consistent with the repayment logic above. + +### SDK usage + +```typescript +import { PoolClient } from '@astera/sdk'; + +const pool = new PoolClient({ rpcUrl, network, contractId: POOL_CONTRACT_ID }); + +// List a co-funding share +const listingId = await pool.listPosition({ + signer, + seller: sellerAddress, + invoiceId: 42n, + kind: 'CoFunding', + amountOrBps: 5000n, + price: 4800n, +}); + +// Buy a listing +await pool.buyListing({ signer, buyer: buyerAddress, listingId }); + +// Cancel a listing +await pool.cancelListing({ signer, seller: sellerAddress, listingId }); + +// Query +const listing = await pool.getListing(listingId); +const invoiceListings = await pool.listListingsForInvoice(42n); +const myListings = await pool.listListingsForInvestor(sellerAddress); +``` + +### Events + +| Event symbol | Payload | Description | +|---|---|---| +| `lst_open` | `(listing_id, invoice_id, seller, amount_or_bps, price)` | New listing created | +| `lst_cncl` | `(listing_id, invoice_id, seller)` | Listing cancelled by seller | +| `lst_buy` | `(listing_id, invoice_id, seller, buyer, price)` | Listing filled by buyer | diff --git a/indexer/src/parser.ts b/indexer/src/parser.ts index 1f35d70b..83e25ce6 100644 --- a/indexer/src/parser.ts +++ b/indexer/src/parser.ts @@ -55,6 +55,14 @@ const ORACLE_REGISTRY_EVENT_TYPES = new Set([ 'unpaused', ]); +// #1025: secondary market events emitted by the pool contract under the +// "pool" topic. +const SECONDARY_MARKET_EVENT_TYPES = new Set([ + 'lst_open', + 'lst_cncl', + 'lst_buy', +]); + // #867: compliance contract emits under the "COMPLY" topic const COMPLIANCE_EVENT_TYPES = new Set([ 'screened', @@ -223,6 +231,14 @@ function extractActor(contractType: string, eventType: string, value: any): stri case 'col_dep': if (isStellarAddress(value[0])) return value[0]; break; + // #1025: secondary market — seller is value[2], buyer is value[3] + case 'lst_open': + case 'lst_cncl': + if (isStellarAddress(value[2])) return value[2]; + break; + case 'lst_buy': + if (isStellarAddress(value[3])) return value[3]; + break; } } else if (contractType === 'INVOICE') { switch (eventType) { diff --git a/packages/sdk/src/clients/pool.ts b/packages/sdk/src/clients/pool.ts index 9bf8e51c..649d1d67 100644 --- a/packages/sdk/src/clients/pool.ts +++ b/packages/sdk/src/clients/pool.ts @@ -14,6 +14,8 @@ import type { FundedInvoice, PoolTokenTotals, TransactionProgress, + Listing, + ListingKind, } from '../types'; import type { Signer } from '../types'; @@ -632,4 +634,115 @@ export class PoolClient extends BaseClient { totalFeeRevenue: BigInt(String(raw.total_fee_revenue)), }; } + + // #1025: secondary market + + private listingFromRaw(raw: Record): Listing { + return { + listingId: BigInt(String(raw.listing_id)), + invoiceId: BigInt(String(raw.invoice_id)), + seller: raw.seller as string, + token: raw.token as string, + kind: (Array.isArray(raw.kind) ? raw.kind[0] : raw.kind) as ListingKind, + amountOrBps: BigInt(String(raw.amount_or_bps)), + price: BigInt(String(raw.price)), + createdAt: Number(raw.created_at), + status: (Array.isArray(raw.status) ? raw.status[0] : raw.status) as Listing['status'], + }; + } + + private listingKindToScVal(kind: ListingKind): xdr.ScVal { + return xdr.ScVal.scvVec([nativeToScVal(kind, { type: 'symbol' })]); + } + + /** List part or all of a position for sale on the secondary market. */ + async listPosition(params: { + signer: Signer; + seller: string; + invoiceId: bigint | number; + kind: ListingKind; + amountOrBps: bigint; + price: bigint; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.seller, + 'list_position', + [ + new Address(params.seller).toScVal(), + nativeToScVal(params.invoiceId, { type: 'u64' }), + this.listingKindToScVal(params.kind), + nativeToScVal(params.amountOrBps, { type: 'u64' }), + nativeToScVal(params.price, { type: 'i128' }), + ], + params.onProgress, + ); + } + + /** Cancel an open listing. Only the original seller may cancel. */ + async cancelListing(params: { + signer: Signer; + seller: string; + listingId: bigint | number; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.seller, + 'cancel_listing', + [ + new Address(params.seller).toScVal(), + nativeToScVal(params.listingId, { type: 'u64' }), + ], + params.onProgress, + ); + } + + /** Buy an open listing. Buyer's available balance is debited by the price. */ + async buyListing(params: { + signer: Signer; + buyer: string; + listingId: bigint | number; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.buyer, + 'buy_listing', + [ + new Address(params.buyer).toScVal(), + nativeToScVal(params.listingId, { type: 'u64' }), + ], + params.onProgress, + ); + } + + /** Fetch a single listing by ID. Returns null if not found. */ + async getListing(listingId: bigint | number): Promise { + const sim = await this.simulate('get_listing', [ + nativeToScVal(listingId, { type: 'u64' }), + ]); + if (StellarRpc.Api.isSimulationError(sim)) return null; + const raw = scValToNative(sim.result!.retval); + if (!raw) return null; + return this.listingFromRaw(raw as Record); + } + + /** All listing IDs for a given invoice (open and closed). */ + async listListingsForInvoice(invoiceId: bigint | number): Promise { + const sim = await this.simulate('list_listings_for_invoice', [ + nativeToScVal(invoiceId, { type: 'u64' }), + ]); + if (StellarRpc.Api.isSimulationError(sim)) return []; + const raw = scValToNative(sim.result!.retval) as unknown[]; + return (raw ?? []).map((id) => BigInt(String(id))); + } + + /** All listing IDs created by a given seller (open and closed). */ + async listListingsForInvestor(seller: string): Promise { + const sim = await this.simulate('list_listings_for_investor', [ + new Address(seller).toScVal(), + ]); + if (StellarRpc.Api.isSimulationError(sim)) return []; + const raw = scValToNative(sim.result!.retval) as unknown[]; + return (raw ?? []).map((id) => BigInt(String(id))); + } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 20719044..b4350195 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -110,6 +110,23 @@ export interface FundedInvoice { export type CoFundingStatus = 'Open' | 'Filled' | 'Cancelled' | 'Expired'; +// #1025: secondary market +export type ListingStatus = 'Open' | 'Filled' | 'Cancelled'; +export type ListingKind = 'CoFunding' | 'SingleFunded'; + +export interface Listing { + listingId: bigint; + invoiceId: bigint; + seller: string; + token: string; + kind: ListingKind; + /** bps of CoFundShare (CoFunding) or raw token amount (SingleFunded) */ + amountOrBps: bigint; + price: bigint; + createdAt: number; + status: ListingStatus; +} + export interface CoFundingRound { invoiceId: bigint; token: string; diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 6b6d2d45..bbf41e7b 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -29,5 +29,8 @@ export type { ProposalStatus, ActionPayload, Proposal, + Listing, + ListingStatus, + ListingKind, } from '../../packages/sdk/src/types'; export { ALL_ROLES, ROLE_LABELS } from '../../packages/sdk/src/types';