diff --git a/Cargo.lock b/Cargo.lock index 17524a1a..01c5b802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tranche" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/Cargo.toml b/Cargo.toml index bfb33119..4b359ebd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "contracts/auction", "contracts/tests", "benchmarks", + "contracts/tranche", ] resolver = "2" diff --git a/contracts/tranche/Cargo.toml b/contracts/tranche/Cargo.toml new file mode 100644 index 00000000..343065b3 --- /dev/null +++ b/contracts/tranche/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "tranche" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.4" + +[features] +testutils = [] diff --git a/contracts/tranche/src/deposit.rs b/contracts/tranche/src/deposit.rs new file mode 100644 index 00000000..c1dc2da2 --- /dev/null +++ b/contracts/tranche/src/deposit.rs @@ -0,0 +1,78 @@ +use soroban_sdk::{panic_with_error, Address, Env}; + +use crate::{ + errors::TrancheError, + events::{DEPOSIT, EVT}, + state::{ + DataKey, InvestorPosition, TrancheAccounting, TrancheClass, TranchePool, + }, +}; + +pub fn deposit( + env: &Env, + investor: Address, + token: Address, + tranche: TrancheClass, + amount: i128, +) { + if amount <= 0 { + panic_with_error!(env, TrancheError::InvalidAmount); + } + + investor.require_auth(); + + let mut pool: TranchePool = env + .storage() + .instance() + .get(&DataKey::Pool(token.clone())) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + let key = DataKey::Investor( + investor.clone(), + token.clone(), + tranche, + ); + + let mut position: InvestorPosition = env + .storage() + .instance() + .get(&key) + .unwrap_or_default(); + + match tranche { + TrancheClass::Senior => { + // Enforce senior advance rate: seniors can't exceed their configured + // percentage of total pool value + let new_total_deposited = pool.junior.deposited + pool.senior.deposited + amount; + let senior_target = pool.config.senior_advance_rate_bps as i128 * new_total_deposited / 10_000; + if pool.senior.deposited + amount > senior_target { + panic_with_error!(env, TrancheError::AdvanceRateExceeded); + } + update_accounting(&mut pool.senior, amount); + } + TrancheClass::Junior => { + update_accounting(&mut pool.junior, amount); + } + } + + position.deposited += amount; + position.shares += amount; + + env.storage().instance().set(&key, &position); + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, DEPOSIT), + (investor, token, tranche, amount), + ); +} + +fn update_accounting( + accounting: &mut TrancheAccounting, + amount: i128, +) { + accounting.deposited += amount; + accounting.available += amount; +} \ No newline at end of file diff --git a/contracts/tranche/src/errors.rs b/contracts/tranche/src/errors.rs new file mode 100644 index 00000000..bc9ebf26 --- /dev/null +++ b/contracts/tranche/src/errors.rs @@ -0,0 +1,18 @@ +use soroban_sdk::contracterror; + +#[contracterror] +#[derive(Copy, Clone, Eq, PartialEq)] +#[repr(u32)] +pub enum TrancheError { + AlreadyInitialized = 1, + Unauthorized = 2, + PoolNotFound = 3, + InvalidAmount = 4, + InsufficientBalance = 5, + AdvanceRateExceeded = 6, + InvalidTranche = 7, + ArithmeticOverflow = 8, + WaterfallError = 9, + LossAllocationError = 10, + ReentrancyDetected = 11, +} \ No newline at end of file diff --git a/contracts/tranche/src/events.rs b/contracts/tranche/src/events.rs new file mode 100644 index 00000000..aaf932b5 --- /dev/null +++ b/contracts/tranche/src/events.rs @@ -0,0 +1,10 @@ +use soroban_sdk::{symbol_short, Symbol}; + +pub const EVT: Symbol = symbol_short!("tranche"); + +pub const DEPOSIT: Symbol = symbol_short!("deposit"); +pub const WITHDRAW: Symbol = symbol_short!("withdraw"); +pub const FUND: Symbol = symbol_short!("fund"); +pub const REPAY: Symbol = symbol_short!("repay"); +pub const DEFAULT: Symbol = symbol_short!("default"); +pub const CONFIG: Symbol = symbol_short!("config"); \ No newline at end of file diff --git a/contracts/tranche/src/funding.rs b/contracts/tranche/src/funding.rs new file mode 100644 index 00000000..99159cf9 --- /dev/null +++ b/contracts/tranche/src/funding.rs @@ -0,0 +1,75 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env}; + +use crate::{ + errors::TrancheError, + events::{EVT, FUND}, + state::{DataKey, InvoiceTrancheExposure, TranchePool}, +}; + +#[contracttype] +#[derive(Clone)] +pub struct TrancheFundingSplit { + pub senior_amount: i128, + pub junior_amount: i128, +} + +pub fn fund_invoice_from_tranches( + env: &Env, + token: Address, + invoice_id: u64, + total_amount: i128, +) -> TrancheFundingSplit { + let mut pool: TranchePool = env + .storage() + .instance() + .get(&DataKey::Pool(token.clone())) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + // Calculate proportional split based on senior advance rate + let senior_amount = total_amount * pool.config.senior_advance_rate_bps as i128 / 10_000; + let junior_amount = total_amount - senior_amount; + + // Check availability + if pool.senior.available < senior_amount { + panic_with_error!(env, TrancheError::InsufficientBalance); + } + if pool.junior.available < junior_amount { + panic_with_error!(env, TrancheError::InsufficientBalance); + } + + // Update accounting + pool.senior.available -= senior_amount; + pool.senior.deployed += senior_amount; + pool.junior.available -= junior_amount; + pool.junior.deployed += junior_amount; + + // Record tranche exposure for this invoice + let exposure = InvoiceTrancheExposure { + invoice_id, + senior_deployed: senior_amount, + junior_deployed: junior_amount, + }; + env.storage() + .instance() + .set(&DataKey::InvoiceExposure(invoice_id), &exposure); + + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, FUND), + (invoice_id, token, senior_amount, junior_amount), + ); + + TrancheFundingSplit { + senior_amount, + junior_amount, + } +} + +pub fn get_invoice_exposure(env: &Env, invoice_id: u64) -> Option { + env.storage() + .instance() + .get(&DataKey::InvoiceExposure(invoice_id)) +} diff --git a/contracts/tranche/src/lib.rs b/contracts/tranche/src/lib.rs new file mode 100644 index 00000000..d5b903b2 --- /dev/null +++ b/contracts/tranche/src/lib.rs @@ -0,0 +1,358 @@ +#![no_std] + +pub mod errors; +pub mod events; +pub mod math; +pub mod state; +pub mod deposit; +pub mod withdraw; +pub mod funding; +pub mod repayment; + +use errors::TrancheError; +use events::{CONFIG, EVT}; +use state::{DataKey, TrancheAccounting, TrancheClass, TrancheConfig, TranchePool}; + +use soroban_sdk::{ + contract, + contractimpl, + panic_with_error, + Address, + Env, +}; + +const REENTRANCY_GUARD: u64 = 1; + +#[contract] +pub struct TrancheContract; + +#[contractimpl] +impl TrancheContract { + fn non_reentrant_start(env: &Env) { + if env.storage().instance().has(&DataKey::NonReentrantKey) { + panic_with_error!(env, TrancheError::ReentrancyDetected); + } + env.storage().instance().set(&DataKey::NonReentrantKey, &REENTRANCY_GUARD); + } + + fn non_reentrant_end(env: &Env) { + env.storage().instance().remove(&DataKey::NonReentrantKey); + } + pub fn initialize( + env: Env, + admin: Address, + token: Address, + senior_share_token: Address, + junior_share_token: Address, + senior_target_yield_bps: u32, + senior_advance_rate_bps: u32, + junior_first_loss_bps: u32, + ) { + if env.storage().instance().has(&DataKey::Admin) { + panic_with_error!(&env, TrancheError::AlreadyInitialized); + } + + admin.require_auth(); + + env.storage().instance().set(&DataKey::Admin, &admin); + + let config = TrancheConfig { + senior_target_yield_bps, + senior_advance_rate_bps, + junior_first_loss_bps, + }; + + let pool = TranchePool { + token: token.clone(), + senior_share_token, + junior_share_token, + config, + senior: TrancheAccounting::default(), + junior: TrancheAccounting::default(), + }; + + env.storage() + .instance() + .set(&DataKey::Pool(token), &pool); + } + + pub fn get_pool( + env: Env, + token: Address, + ) -> TranchePool { + env.storage() + .instance() + .get(&DataKey::Pool(token)) + .unwrap_or_else(|| { + panic_with_error!(&env, TrancheError::PoolNotFound) + }) + } + + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap() + } + + pub fn get_config( + env: Env, + token: Address, + ) -> TrancheConfig { + let pool = Self::get_pool(env, token); + pool.config + } + + pub fn get_totals( + env: Env, + token: Address, + tranche: TrancheClass, + ) -> TrancheAccounting { + let pool = Self::get_pool(env, token); + + match tranche { + TrancheClass::Senior => pool.senior, + TrancheClass::Junior => pool.junior, + } + } + + pub fn deposit_tranche( + env: Env, + investor: Address, + token: Address, + tranche: TrancheClass, + amount: i128, + ) { + Self::non_reentrant_start(&env); + deposit::deposit( + &env, + investor, + token, + tranche, + amount, + ); + Self::non_reentrant_end(&env); + } + + pub fn withdraw_tranche( + env: Env, + investor: Address, + token: Address, + tranche: TrancheClass, + amount: i128, + ) { + Self::non_reentrant_start(&env); + withdraw::withdraw( + &env, + investor, + token, + tranche, + amount, + ); + Self::non_reentrant_end(&env); + } + + pub fn get_position( + env: Env, + investor: Address, + token: Address, + tranche: TrancheClass, + ) -> state::InvestorPosition { + env.storage() + .instance() + .get(&DataKey::Investor( + investor, + token, + tranche, + )) + .unwrap_or_default() + } + + pub fn set_tranche_config( + env: Env, + admin: Address, + token: Address, + senior_target_yield_bps: u32, + senior_advance_rate_bps: u32, + junior_first_loss_bps: u32, + ) { + admin.require_auth(); + + let stored_admin = Self::get_admin(env.clone()); + if admin != stored_admin { + panic_with_error!(&env, TrancheError::Unauthorized); + } + + let mut pool = Self::get_pool(env.clone(), token.clone()); + pool.config = TrancheConfig { + senior_target_yield_bps, + senior_advance_rate_bps, + junior_first_loss_bps, + }; + + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, CONFIG), + (token, senior_target_yield_bps, senior_advance_rate_bps, junior_first_loss_bps), + ); + } + + pub fn open_tranche_for_token( + env: Env, + admin: Address, + token: Address, + senior_share_token: Address, + junior_share_token: Address, + senior_target_yield_bps: u32, + senior_advance_rate_bps: u32, + junior_first_loss_bps: u32, + ) { + admin.require_auth(); + + let stored_admin = Self::get_admin(env.clone()); + if admin != stored_admin { + panic_with_error!(&env, TrancheError::Unauthorized); + } + + if env.storage().instance().has(&DataKey::TrancheEnabled(token.clone())) { + panic_with_error!(&env, TrancheError::AlreadyInitialized); + } + + let config = TrancheConfig { + senior_target_yield_bps, + senior_advance_rate_bps, + junior_first_loss_bps, + }; + + let pool = TranchePool { + token: token.clone(), + senior_share_token, + junior_share_token, + config, + senior: TrancheAccounting::default(), + junior: TrancheAccounting::default(), + }; + + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + env.storage() + .instance() + .set(&DataKey::TrancheEnabled(token.clone()), &true); + + env.events().publish( + (EVT, CONFIG), + (token, senior_target_yield_bps, senior_advance_rate_bps, junior_first_loss_bps), + ); + } + + pub fn is_tranche_enabled(env: Env, token: Address) -> bool { + env.storage() + .instance() + .get(&DataKey::TrancheEnabled(token)) + .unwrap_or(false) + } + + pub fn fund_invoice_from_tranches( + env: Env, + token: Address, + invoice_id: u64, + total_amount: i128, + ) -> (i128, i128) { + Self::non_reentrant_start(&env); + let result = funding::fund_invoice_from_tranches(&env, token, invoice_id, total_amount); + Self::non_reentrant_end(&env); + (result.senior_amount, result.junior_amount) + } + + pub fn get_invoice_exposure( + env: Env, + invoice_id: u64, + ) -> Option { + funding::get_invoice_exposure(&env, invoice_id) + } + + pub fn distribute_waterfall_repayment( + env: Env, + token: Address, + invoice_id: u64, + total_due: i128, + elapsed_secs: u64, + ) -> (i128, i128) { + Self::non_reentrant_start(&env); + let result = repayment::distribute_waterfall_repayment( + &env, + token, + invoice_id, + total_due, + elapsed_secs, + ); + Self::non_reentrant_end(&env); + result + } + + pub fn allocate_loss( + env: Env, + token: Address, + invoice_id: u64, + shortfall: i128, + ) { + Self::non_reentrant_start(&env); + repayment::allocate_loss(&env, token, invoice_id, shortfall); + Self::non_reentrant_end(&env); + } + + pub fn simulate_waterfall( + env: Env, + token: Address, + invoice_id: u64, + hypothetical_repayment: i128, + elapsed_secs: u64, + ) -> (i128, i128) { + let pool = Self::get_pool(env.clone(), token); + let exposure: state::InvoiceTrancheExposure = env + .storage() + .instance() + .get(&DataKey::InvoiceExposure(invoice_id)) + .unwrap_or_else(|| panic_with_error!(&env, TrancheError::PoolNotFound)); + + math::calculate_waterfall_split( + &env, + hypothetical_repayment, + exposure.senior_deployed, + pool.config.senior_target_yield_bps, + elapsed_secs, + ) + } + + pub fn get_effective_apy( + env: Env, + token: Address, + tranche: TrancheClass, + ) -> u32 { + let pool = Self::get_pool(env.clone(), token); + let accounting = match tranche { + TrancheClass::Senior => pool.senior, + TrancheClass::Junior => pool.junior, + }; + + if accounting.deposited == 0 { + return 0; + } + + // Calculate realized APY based on earned vs deposited + // This is a simplified calculation - in production would use time-weighted returns + let total_return = accounting.earned - accounting.losses; + if total_return <= 0 { + return 0; + } + + // Convert to basis points (annualized) + let return_bps = (total_return * 10_000) / accounting.deposited; + return_bps as u32 + } + +} \ No newline at end of file diff --git a/contracts/tranche/src/math.rs b/contracts/tranche/src/math.rs new file mode 100644 index 00000000..837d7013 --- /dev/null +++ b/contracts/tranche/src/math.rs @@ -0,0 +1,43 @@ +use soroban_sdk::Env; + +pub fn calculate_waterfall_split( + _env: &Env, + total_due: i128, + senior_principal: i128, + senior_target_yield_bps: u32, + elapsed_secs: u64, +) -> (i128, i128) { + let yearly = 365u64 * 24 * 60 * 60; + + let interest = senior_principal + * senior_target_yield_bps as i128 + * elapsed_secs as i128 + / 10_000 + / yearly as i128; + + let senior_cap = senior_principal + interest; + + let senior_amount = if total_due >= senior_cap { + senior_cap + } else { + total_due + }; + + let junior_amount = total_due - senior_amount; + + (senior_amount, junior_amount) +} + +pub fn calculate_loss_allocation( + shortfall: i128, + junior_remaining: i128, +) -> (i128, i128) { + if shortfall <= junior_remaining { + (shortfall, 0) + } else { + ( + junior_remaining, + shortfall - junior_remaining, + ) + } +} \ No newline at end of file diff --git a/contracts/tranche/src/repayment.rs b/contracts/tranche/src/repayment.rs new file mode 100644 index 00000000..ddd3ec53 --- /dev/null +++ b/contracts/tranche/src/repayment.rs @@ -0,0 +1,123 @@ +use soroban_sdk::{panic_with_error, Address, Env}; + +use crate::{ + errors::TrancheError, + events::{DEFAULT, EVT, REPAY}, + math::{calculate_loss_allocation, calculate_waterfall_split}, + state::{DataKey, InvoiceTrancheExposure, TranchePool}, +}; + +pub fn distribute_waterfall_repayment( + env: &Env, + token: Address, + invoice_id: u64, + total_due: i128, + elapsed_secs: u64, +) -> (i128, i128) { + let mut pool: TranchePool = env + .storage() + .instance() + .get(&DataKey::Pool(token.clone())) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + // Get invoice exposure to determine senior principal for this invoice + let exposure: InvoiceTrancheExposure = env + .storage() + .instance() + .get(&DataKey::InvoiceExposure(invoice_id)) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + // Calculate waterfall split + let (senior_amount, junior_amount) = calculate_waterfall_split( + env, + total_due, + exposure.senior_deployed, + pool.config.senior_target_yield_bps, + elapsed_secs, + ); + + // Update senior accounting + pool.senior.deployed -= exposure.senior_deployed; + pool.senior.earned += senior_amount; + pool.senior.available += senior_amount; + + // Update junior accounting + pool.junior.deployed -= exposure.junior_deployed; + pool.junior.earned += junior_amount; + pool.junior.available += junior_amount; + + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, REPAY), + (invoice_id, token, senior_amount, junior_amount), + ); + + (senior_amount, junior_amount) +} + +pub fn allocate_loss( + env: &Env, + token: Address, + invoice_id: u64, + shortfall: i128, +) { + let mut pool: TranchePool = env + .storage() + .instance() + .get(&DataKey::Pool(token.clone())) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + // Get invoice exposure + let exposure: InvoiceTrancheExposure = env + .storage() + .instance() + .get(&DataKey::InvoiceExposure(invoice_id)) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + // Calculate loss allocation + let junior_remaining = pool.junior.deployed + pool.junior.available; + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + // Apply junior loss + if junior_loss > 0 { + pool.junior.losses += junior_loss; + // Reduce junior's deployed/available proportionally + let junior_total = pool.junior.deployed + pool.junior.available; + if junior_total > 0 { + let deployed_ratio = pool.junior.deployed as u128 / junior_total as u128; + let available_loss = junior_loss - (junior_loss as u128 * deployed_ratio) as i128; + let deployed_loss = junior_loss - available_loss; + pool.junior.deployed -= deployed_loss; + pool.junior.available -= available_loss; + } + } + + // Apply senior loss (only after junior is exhausted) + if senior_loss > 0 { + pool.senior.losses += senior_loss; + let senior_total = pool.senior.deployed + pool.senior.available; + if senior_total > 0 { + let deployed_ratio = pool.senior.deployed as u128 / senior_total as u128; + let available_loss = senior_loss - (senior_loss as u128 * deployed_ratio) as i128; + let deployed_loss = senior_loss - available_loss; + pool.senior.deployed -= deployed_loss; + pool.senior.available -= available_loss; + } + } + + // Remove deployed amounts from this invoice + pool.senior.deployed -= exposure.senior_deployed; + pool.junior.deployed -= exposure.junior_deployed; + + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, DEFAULT), + (invoice_id, token, junior_loss, senior_loss), + ); +} diff --git a/contracts/tranche/src/state.rs b/contracts/tranche/src/state.rs new file mode 100644 index 00000000..a0a92358 --- /dev/null +++ b/contracts/tranche/src/state.rs @@ -0,0 +1,65 @@ +use soroban_sdk::{contracttype, Address}; + +#[contracttype] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum TrancheClass { + Senior, + Junior, +} + +#[contracttype] +#[derive(Clone, Copy)] +pub struct TrancheConfig { + pub senior_target_yield_bps: u32, + pub senior_advance_rate_bps: u32, + pub junior_first_loss_bps: u32, +} + +#[contracttype] +#[derive(Clone, Default, Copy)] +pub struct TrancheAccounting { + pub deposited: i128, + pub available: i128, + pub deployed: i128, + pub earned: i128, + pub losses: i128, +} + +#[contracttype] +#[derive(Clone)] +pub struct TranchePool { + pub token: Address, + pub senior_share_token: Address, + pub junior_share_token: Address, + pub config: TrancheConfig, + pub senior: TrancheAccounting, + pub junior: TrancheAccounting, +} + +#[contracttype] +#[derive(Clone, Default)] +pub struct InvestorPosition { + pub deposited: i128, + pub shares: i128, + pub earned: i128, + pub losses: i128, +} + +#[contracttype] +#[derive(Clone)] +pub struct InvoiceTrancheExposure { + pub invoice_id: u64, + pub senior_deployed: i128, + pub junior_deployed: i128, +} + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + Pool(Address), + Investor(Address, Address, TrancheClass), + TrancheEnabled(Address), + NonReentrantKey, + InvoiceExposure(u64), +} \ No newline at end of file diff --git a/contracts/tranche/src/withdraw.rs b/contracts/tranche/src/withdraw.rs new file mode 100644 index 00000000..39ad8f9b --- /dev/null +++ b/contracts/tranche/src/withdraw.rs @@ -0,0 +1,80 @@ +use soroban_sdk::{panic_with_error, Address, Env}; + +use crate::{ + errors::TrancheError, + events::{EVT, WITHDRAW}, + state::{ + DataKey, InvestorPosition, TrancheAccounting, TrancheClass, TranchePool, + }, +}; + +pub fn withdraw( + env: &Env, + investor: Address, + token: Address, + tranche: TrancheClass, + amount: i128, +) { + if amount <= 0 { + panic_with_error!(env, TrancheError::InvalidAmount); + } + + investor.require_auth(); + + let mut pool: TranchePool = env + .storage() + .instance() + .get(&DataKey::Pool(token.clone())) + .unwrap_or_else(|| panic_with_error!(env, TrancheError::PoolNotFound)); + + let key = DataKey::Investor( + investor.clone(), + token.clone(), + tranche, + ); + + let mut position: InvestorPosition = env + .storage() + .instance() + .get(&key) + .unwrap_or_default(); + + if position.shares < amount { + panic_with_error!(env, TrancheError::InsufficientBalance); + } + + match tranche { + TrancheClass::Senior => { + update_accounting(env, &mut pool.senior, amount); + } + TrancheClass::Junior => { + update_accounting(env, &mut pool.junior, amount); + } + } + + position.shares -= amount; + position.deposited -= amount; + + env.storage().instance().set(&key, &position); + env.storage() + .instance() + .set(&DataKey::Pool(token.clone()), &pool); + + env.events().publish( + (EVT, WITHDRAW), + (investor, token, tranche, amount), + ); +} + +fn update_accounting( + env: &Env, + accounting: &mut TrancheAccounting, + amount: i128, +) { + if accounting.available < amount { + panic_with_error!(env, TrancheError::InsufficientBalance); + } + + accounting.available -= amount; + accounting.deposited -= amount; +} \ No newline at end of file diff --git a/contracts/tranche/tests/advance_rate_tests.rs b/contracts/tranche/tests/advance_rate_tests.rs new file mode 100644 index 00000000..e1a4518a --- /dev/null +++ b/contracts/tranche/tests/advance_rate_tests.rs @@ -0,0 +1,135 @@ +use soroban_sdk::{Address, Env}; +use soroban_sdk::testutils::Address as _; +use tranche::{state::TrancheClass, TrancheContract}; + +#[test] +fn test_advance_rate_enforcement() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, // senior_target_yield_bps + 8000, // senior_advance_rate_bps: 80% + 10000, // junior_first_loss_bps + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // First, deposit junior to create capacity + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 2000, + ); + }); + + // Senior can deposit up to 80% of total (2000 junior + X senior) + // 0.8 * (2000 + X) = X => 1600 + 0.8X = X => 1600 = 0.2X => X = 8000 + // So senior can deposit up to 8000 with 2000 junior + + // This should succeed + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 8000, + ); + }); + + // Verify the pool state + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deposited, 8000); + assert_eq!(pool.junior.deposited, 2000); + }); +} + +#[test] +fn test_advance_rate_100_percent() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, + 10000, // 100% advance rate + 10000, + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 10000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + + // With 100% advance rate, senior can deposit without junior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 10000, + ); + }); + + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deposited, 10000); + }); +} diff --git a/contracts/tranche/tests/fuzz_tests.rs b/contracts/tranche/tests/fuzz_tests.rs new file mode 100644 index 00000000..8eb8a86e --- /dev/null +++ b/contracts/tranche/tests/fuzz_tests.rs @@ -0,0 +1,156 @@ +#![cfg(test)] + +use proptest::prelude::*; +use soroban_sdk::Env; +use tranche::math::{calculate_loss_allocation, calculate_waterfall_split}; + +const YEAR_SECS: u64 = 365 * 24 * 60 * 60; + +// ── dust / rounding boundary tests ────────────────────────────────────────── + +#[test] +fn test_waterfall_split_dust_total_due() { + let env = Env::default(); + // 1-unit total_due: all goes to senior, junior gets 0 + let (s, j) = calculate_waterfall_split(&env, 1, 1_000_000, 1000, YEAR_SECS); + assert_eq!(s, 1); + assert_eq!(j, 0); +} + +#[test] +fn test_waterfall_split_elapsed_secs_one() { + let env = Env::default(); + // elapsed_secs = 1: interest rounds to 0 for typical principals, senior_cap == principal + let (s, j) = calculate_waterfall_split(&env, 2_000, 1_000, 1000, 1); + // interest = 1000 * 1000 * 1 / 10_000 / 31_536_000 = 0 + assert_eq!(s, 1_000); + assert_eq!(j, 1_000); +} + +#[test] +fn test_waterfall_split_total_due_one_below_cap() { + let env = Env::default(); + // senior_cap = 1100 (1000 principal + 10% for 1 year); total_due = 1099 + let (s, j) = calculate_waterfall_split(&env, 1_099, 1_000, 1000, YEAR_SECS); + assert_eq!(s, 1_099); + assert_eq!(j, 0); +} + +#[test] +fn test_waterfall_split_total_due_one_above_cap() { + let env = Env::default(); + // total_due = 1101, cap = 1100 + let (s, j) = calculate_waterfall_split(&env, 1_101, 1_000, 1000, YEAR_SECS); + assert_eq!(s, 1_100); + assert_eq!(j, 1); +} + +#[test] +fn test_loss_allocation_dust_shortfall() { + // 1-unit shortfall, junior has plenty + let (jl, sl) = calculate_loss_allocation(1, 1_000_000); + assert_eq!(jl, 1); + assert_eq!(sl, 0); +} + +#[test] +fn test_loss_allocation_shortfall_one_above_junior() { + // shortfall = junior + 1: junior wiped, senior takes 1 + let (jl, sl) = calculate_loss_allocation(101, 100); + assert_eq!(jl, 100); + assert_eq!(sl, 1); +} + +#[test] +fn test_loss_allocation_shortfall_one_below_junior() { + // shortfall = junior - 1: junior absorbs all, senior untouched + let (jl, sl) = calculate_loss_allocation(99, 100); + assert_eq!(jl, 99); + assert_eq!(sl, 0); +} + +// ── property tests ─────────────────────────────────────────────────────────── + +proptest! { + #![proptest_config(ProptestConfig::with_cases(500))] + + /// senior_amount + junior_amount == total_due for all inputs + #[test] + fn prop_waterfall_split_sum_invariant( + total_due in 0i128..1_000_000_000i128, + senior_principal in 1i128..500_000_000i128, + yield_bps in 0u32..5_000u32, + elapsed in 1u64..YEAR_SECS * 5, + ) { + let env = Env::default(); + let (s, j) = calculate_waterfall_split(&env, total_due, senior_principal, yield_bps, elapsed); + prop_assert_eq!(s + j, total_due, "sum must equal total_due"); + } + + /// senior_amount never exceeds the computed cap + #[test] + fn prop_waterfall_split_senior_never_exceeds_cap( + total_due in 0i128..1_000_000_000i128, + senior_principal in 1i128..500_000_000i128, + yield_bps in 0u32..5_000u32, + elapsed in 1u64..YEAR_SECS * 5, + ) { + let env = Env::default(); + let interest = senior_principal * yield_bps as i128 * elapsed as i128 + / 10_000 + / YEAR_SECS as i128; + let cap = senior_principal + interest; + let (s, _) = calculate_waterfall_split(&env, total_due, senior_principal, yield_bps, elapsed); + prop_assert!(s <= cap, "senior {} must not exceed cap {}", s, cap); + } + + /// senior_amount is monotonically non-decreasing in total_due + #[test] + fn prop_waterfall_split_monotonic_senior( + lower in 0i128..500_000_000i128, + delta in 0i128..500_000_000i128, + senior_principal in 1i128..500_000_000i128, + yield_bps in 0u32..5_000u32, + elapsed in 1u64..YEAR_SECS * 5, + ) { + let env = Env::default(); + let higher = lower + delta; + let (s_low, _) = calculate_waterfall_split(&env, lower, senior_principal, yield_bps, elapsed); + let (s_high, _) = calculate_waterfall_split(&env, higher, senior_principal, yield_bps, elapsed); + prop_assert!(s_high >= s_low, "senior must be non-decreasing in total_due"); + } + + /// junior_loss + senior_loss == shortfall for all inputs + #[test] + fn prop_loss_allocation_sum_invariant( + shortfall in 0i128..1_000_000_000i128, + junior_remaining in 0i128..1_000_000_000i128, + ) { + let (jl, sl) = calculate_loss_allocation(shortfall, junior_remaining); + prop_assert_eq!(jl + sl, shortfall, "loss sum must equal shortfall"); + } + + /// junior absorbs first; senior_loss > 0 only when junior is exhausted + #[test] + fn prop_loss_allocation_junior_first( + shortfall in 0i128..1_000_000_000i128, + junior_remaining in 0i128..1_000_000_000i128, + ) { + let (jl, sl) = calculate_loss_allocation(shortfall, junior_remaining); + if shortfall <= junior_remaining { + prop_assert_eq!(sl, 0, "senior must take no loss when junior covers shortfall"); + } else { + prop_assert_eq!(jl, junior_remaining, "junior must be fully wiped before senior takes loss"); + } + } + + /// junior_loss never exceeds junior_remaining + #[test] + fn prop_loss_allocation_junior_loss_bounded( + shortfall in 0i128..1_000_000_000i128, + junior_remaining in 0i128..1_000_000_000i128, + ) { + let (jl, _) = calculate_loss_allocation(shortfall, junior_remaining); + prop_assert!(jl <= junior_remaining, "junior loss {} must not exceed junior_remaining {}", jl, junior_remaining); + } +} diff --git a/contracts/tranche/tests/math_tests.rs b/contracts/tranche/tests/math_tests.rs new file mode 100644 index 00000000..7900ea29 --- /dev/null +++ b/contracts/tranche/tests/math_tests.rs @@ -0,0 +1,224 @@ +use soroban_sdk::Env; +use tranche::math::{calculate_loss_allocation, calculate_waterfall_split}; + +#[test] +fn test_waterfall_split_senior_cap() { + let env = Env::default(); + + // Test case: senior gets capped return, junior gets remainder + let senior_principal = 1000; + let senior_target_yield_bps = 1000; // 10% + let elapsed_secs = 365 * 24 * 60 * 60; // 1 year + + let total_due = 1200; // More than senior cap (1100) + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + + assert_eq!(senior_amount, 1100); // Principal + 10% yield + assert_eq!(junior_amount, 100); // Remainder +} + +#[test] +fn test_waterfall_split_partial_payment() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 1000; // 10% + let elapsed_secs = 365 * 24 * 60 * 60; + + let total_due = 500; // Less than senior cap + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + + assert_eq!(senior_amount, 500); // All goes to senior + assert_eq!(junior_amount, 0); // Junior gets nothing +} + +#[test] +fn test_waterfall_split_exact_cap() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 1000; // 10% + let elapsed_secs = 365 * 24 * 60 * 60; + + let total_due = 1100; // Exactly senior cap + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + + assert_eq!(senior_amount, 1100); + assert_eq!(junior_amount, 0); +} + +#[test] +fn test_waterfall_split_zero_yield() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 0; // 0% yield + let elapsed_secs = 365 * 24 * 60 * 60; + + let total_due = 1500; + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + + assert_eq!(senior_amount, 1000); // Only principal + assert_eq!(junior_amount, 500); // All excess to junior +} + +#[test] +fn test_waterfall_split_time_proportional() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 1000; // 10% annual + let elapsed_secs = 182 * 24 * 60 * 60; // 6 months + + let total_due = 1500; + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + + // Senior cap should be 1000 + 50 (half of 100) = 1050 (allow for rounding) + assert!(senior_amount >= 1049 && senior_amount <= 1051); + assert!(junior_amount >= 449 && junior_amount <= 451); +} + +#[test] +fn test_waterfall_split_monotonic_senior() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 1000; + let elapsed_secs = 365 * 24 * 60 * 60; + + let mut prev_senior = 0; + for total_due in 0..=2000 { + let (senior_amount, _) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + assert!(senior_amount >= prev_senior, "Senior amount should be monotonically non-decreasing"); + prev_senior = senior_amount; + } +} + +#[test] +fn test_waterfall_split_sum_equals_total() { + let env = Env::default(); + + let senior_principal = 1000; + let senior_target_yield_bps = 1000; + let elapsed_secs = 365 * 24 * 60 * 60; + + for total_due in vec![0, 500, 1000, 1100, 1500, 2000] { + let (senior_amount, junior_amount) = calculate_waterfall_split( + &env, + total_due, + senior_principal, + senior_target_yield_bps, + elapsed_secs, + ); + assert_eq!( + senior_amount + junior_amount, + total_due, + "Sum of senior and junior should equal total due" + ); + } +} + +#[test] +fn test_loss_allocation_junior_absorbs_all() { + let shortfall = 100; + let junior_remaining = 200; + + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + assert_eq!(junior_loss, 100); + assert_eq!(senior_loss, 0); +} + +#[test] +fn test_loss_allocation_junior_exhausted() { + let shortfall = 300; + let junior_remaining = 100; + + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + assert_eq!(junior_loss, 100); // Junior wiped out + assert_eq!(senior_loss, 200); // Senior takes remainder +} + +#[test] +fn test_loss_allocation_exact_shortfall() { + let shortfall = 100; + let junior_remaining = 100; + + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + assert_eq!(junior_loss, 100); + assert_eq!(senior_loss, 0); +} + +#[test] +fn test_loss_allocation_zero_junior_balance() { + let shortfall = 100; + let junior_remaining = 0; + + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + assert_eq!(junior_loss, 0); + assert_eq!(senior_loss, 100); +} + +#[test] +fn test_loss_allocation_zero_shortfall() { + let shortfall = 0; + let junior_remaining = 100; + + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + + assert_eq!(junior_loss, 0); + assert_eq!(senior_loss, 0); +} + +#[test] +fn test_loss_allocation_sum_equals_shortfall() { + for shortfall in vec![0, 50, 100, 200, 500] { + for junior_remaining in vec![0, 50, 100, 200, 500] { + let (junior_loss, senior_loss) = calculate_loss_allocation(shortfall, junior_remaining); + assert_eq!( + junior_loss + senior_loss, + shortfall, + "Loss allocation sum should equal shortfall" + ); + } + } +} diff --git a/contracts/tranche/tests/scenario_tests.rs b/contracts/tranche/tests/scenario_tests.rs new file mode 100644 index 00000000..1d04349c --- /dev/null +++ b/contracts/tranche/tests/scenario_tests.rs @@ -0,0 +1,632 @@ +use soroban_sdk::{Address, Env}; +use soroban_sdk::testutils::Address as _; +use tranche::{state::TrancheClass, TrancheContract}; + +#[test] +fn test_basic_deposit_and_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, // senior_target_yield_bps: 10% + 8000, // senior_advance_rate_bps: 80% + 10000, // junior_first_loss_bps: 100% + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // Deposit junior first to create capacity for senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 2000, + ); + }); + + // Deposit to senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 5000, + ); + }); + + // Verify deposits + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deposited, 5000); + assert_eq!(pool.junior.deposited, 2000); + }); + + // Withdraw from senior + env.as_contract(&contract_id, || { + TrancheContract::withdraw_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 1000, + ); + }); + + // Verify withdrawal + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deposited, 4000); + }); +} + +#[test] +fn test_funding_and_repayment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, + 8000, + 10000, + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // Deposit junior first to create capacity for senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 2000, + ); + }); + + // Deposit to senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 8000, + ); + }); + + // Fund an invoice + env.as_contract(&contract_id, || { + let (senior_amt, junior_amt) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 1, + 1000, + ); + assert_eq!(senior_amt, 800); // 80% of 1000 + assert_eq!(junior_amt, 200); // 20% of 1000 + }); + + // Verify funding state + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deployed, 800); + assert_eq!(pool.junior.deployed, 200); + assert_eq!(pool.senior.available, 7200); // 8000 - 800 + assert_eq!(pool.junior.available, 1800); // 2000 - 200 + }); + + // Repay the invoice + env.as_contract(&contract_id, || { + let (senior_payout, junior_payout) = TrancheContract::distribute_waterfall_repayment( + env.clone(), + token.clone(), + 1, + 1100, // 10% yield + 30 * 24 * 60 * 60, // 30 days + ); + // Senior should get capped amount, junior gets remainder + assert!(senior_payout > 800); + assert!(junior_payout > 200); + }); + + // Verify repayment state + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deployed, 0); // Invoice repaid + assert_eq!(pool.junior.deployed, 0); + assert!(pool.senior.earned > 0); + assert!(pool.junior.earned > 0); + }); +} + +#[test] +fn test_full_scenario_five_invoices_mixed_outcomes() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, // senior_target_yield_bps: 10% + 8000, // senior_advance_rate_bps: 80% + 10000, // junior_first_loss_bps: 100% + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // Deposit junior first to create capacity for senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 2000, + ); + }); + + // Deposit to senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 8000, + ); + }); + + // Fund 5 invoices + // Invoice 1: 1000 (800 senior, 200 junior) + env.as_contract(&contract_id, || { + let (senior1, junior1) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 1, + 1000, + ); + assert_eq!(senior1, 800); + assert_eq!(junior1, 200); + }); + + // Invoice 2: 1500 (1200 senior, 300 junior) + env.as_contract(&contract_id, || { + let (senior2, junior2) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 2, + 1500, + ); + assert_eq!(senior2, 1200); + assert_eq!(junior2, 300); + }); + + // Invoice 3: 2000 (1600 senior, 400 junior) + env.as_contract(&contract_id, || { + let (senior3, junior3) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 3, + 2000, + ); + assert_eq!(senior3, 1600); + assert_eq!(junior3, 400); + }); + + // Invoice 4: 1500 (1200 senior, 300 junior) + env.as_contract(&contract_id, || { + let (senior4, junior4) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 4, + 1500, + ); + assert_eq!(senior4, 1200); + assert_eq!(junior4, 300); + }); + + // Invoice 5: 1000 (800 senior, 200 junior) + env.as_contract(&contract_id, || { + let (senior5, junior5) = TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 5, + 1000, + ); + assert_eq!(senior5, 800); + assert_eq!(junior5, 200); + }); + + // Check totals after funding + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + assert_eq!(pool.senior.deposited, 8000); + assert_eq!(pool.senior.deployed, 5600); // 800+1200+1600+1200+800 + assert_eq!(pool.senior.available, 2400); // 8000-5600 + assert_eq!(pool.junior.deposited, 2000); + assert_eq!(pool.junior.deployed, 1400); // 200+300+400+300+200 + assert_eq!(pool.junior.available, 600); // 2000-1400 + }); + + // Invoice 1: Full repayment (1100: principal + 10% yield) + env.as_contract(&contract_id, || { + let (senior_payout1, junior_payout1) = TrancheContract::distribute_waterfall_repayment( + env.clone(), + token.clone(), + 1, + 1100, + 30 * 24 * 60 * 60, // 30 days + ); + // Senior cap: 800 + (800 * 1000 * 30 days / 365 days / 10000) ≈ 800 + 6.58 = 806.58 + // For simplicity, let's check that senior gets capped amount and junior gets remainder + assert!(senior_payout1 > 800 && senior_payout1 <= 810); + assert!(junior_payout1 > 290); + }); + + // Invoice 2: Full repayment (1650) + env.as_contract(&contract_id, || { + let (senior_payout2, junior_payout2) = TrancheContract::distribute_waterfall_repayment( + env.clone(), + token.clone(), + 2, + 1650, + 30 * 24 * 60 * 60, + ); + assert!(senior_payout2 > 1200 && senior_payout2 <= 1220); + assert!(junior_payout2 > 430); + }); + + // Invoice 3: Full repayment (2200) + env.as_contract(&contract_id, || { + let (senior_payout3, junior_payout3) = TrancheContract::distribute_waterfall_repayment( + env.clone(), + token.clone(), + 3, + 2200, + 30 * 24 * 60 * 60, + ); + assert!(senior_payout3 > 1600 && senior_payout3 <= 1630); + assert!(junior_payout3 > 570); + }); + + // Invoice 4: Partial repayment then default with partial collateral recovery + // First, partial repayment of 500 + env.as_contract(&contract_id, || { + let (senior_payout4_partial, junior_payout4_partial) = TrancheContract::distribute_waterfall_repayment( + env.clone(), + token.clone(), + 4, + 500, + 15 * 24 * 60 * 60, // 15 days elapsed + ); + // All 500 goes to senior since it's below cap + assert_eq!(senior_payout4_partial, 500); + assert_eq!(junior_payout4_partial, 0); + }); + + // Then default with 300 collateral recovered (shortfall of 1000 from remaining 1300) + env.as_contract(&contract_id, || { + TrancheContract::allocate_loss( + env.clone(), + token.clone(), + 4, + 1000, // Shortfall + ); + }); + + // Invoice 5: Full default with zero collateral (shortfall of 1000) + env.as_contract(&contract_id, || { + TrancheContract::allocate_loss( + env.clone(), + token.clone(), + 5, + 1000, // Full principal lost + ); + }); + + // Check final state + env.as_contract(&contract_id, || { + let pool_final = TrancheContract::get_pool(env.clone(), token.clone()); + + // Senior should have earned from invoices 1, 2, 3, and partial from 4 + // Should have lost some from invoice 4 (after junior exhausted) and invoice 5 + assert!(pool_final.senior.earned > 0); + assert!(pool_final.senior.losses > 0); + + // Junior should have earned from invoices 1, 2, 3 + // Should have absorbed losses from invoice 4 and 5 + assert!(pool_final.junior.earned > 0); + assert!(pool_final.junior.losses > 0); + + // Verify that junior absorbed losses first + // Total shortfall: 1000 (invoice 4) + 1000 (invoice 5) = 2000 + // Junior had 1400 deployed + 600 available = 2000 total + // Junior should have absorbed significant losses, but may have earnings from successful invoices + let junior_remaining = pool_final.junior.deposited + pool_final.junior.earned - pool_final.junior.losses; + assert!(junior_remaining < pool_final.junior.deposited, "Junior should have lost principal"); + assert!(pool_final.junior.losses > 1500, "Junior should have absorbed most losses"); + + // Senior should have taken remaining loss after junior was exhausted + // Senior had 5600 deployed - 800 (invoice 1) - 1200 (invoice 2) - 1600 (invoice 3) - 500 (partial invoice 4) = 1500 remaining + // After junior exhausted (2000 loss absorbed), senior takes remaining 1000 loss + assert!(pool_final.senior.losses > 0); + }); +} + +#[test] +fn test_junior_absorbs_full_default_senior_whole() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, // 10% yield + 8000, // 80% advance rate + 10000, // 100% junior first loss + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // Deposit junior first to create capacity + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 2000, + ); + }); + + // Deposit to senior + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 8000, + ); + }); + + // Fund one invoice: 1000 (800 senior, 200 junior) + env.as_contract(&contract_id, || { + TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 1, + 1000, + ); + }); + + // Default with 200 collateral recovered (800 shortfall) + env.as_contract(&contract_id, || { + TrancheContract::allocate_loss( + env.clone(), + token.clone(), + 1, + 800, + ); + }); + + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + + // Junior should have absorbed the entire 800 loss + assert_eq!(pool.junior.losses, 800); + // Senior should have no losses + assert_eq!(pool.senior.losses, 0); + + // Junior should still have 1200 remaining (2000 - 800) + assert_eq!(pool.junior.deposited - pool.junior.losses, 1200); + }); +} + +#[test] +fn test_senior_takes_loss_after_junior_exhausted() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let token = Address::generate(&env); + let senior_share_token = Address::generate(&env); + let junior_share_token = Address::generate(&env); + + let contract_id = env.register_contract(None, TrancheContract); + + env.as_contract(&contract_id, || { + TrancheContract::initialize( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token.clone(), + junior_share_token.clone(), + 1000, + 8000, + 10000, + ); + }); + + env.as_contract(&contract_id, || { + TrancheContract::open_tranche_for_token( + env.clone(), + admin.clone(), + token.clone(), + senior_share_token, + junior_share_token, + 1000, + 8000, + 10000, + ); + }); + + let investor1 = Address::generate(&env); + let investor2 = Address::generate(&env); + + // Deposit junior first to create capacity + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor2.clone(), + token.clone(), + TrancheClass::Junior, + 500, // Small junior buffer + ); + }); + + // Deposit to senior - limited by advance rate + // With 500 junior, senior can deposit up to: 0.8 * (500 + X) = X => X = 2000 + env.as_contract(&contract_id, || { + TrancheContract::deposit_tranche( + env.clone(), + investor1.clone(), + token.clone(), + TrancheClass::Senior, + 2000, + ); + }); + + // Fund invoice: 1000 (800 senior, 200 junior) + env.as_contract(&contract_id, || { + TrancheContract::fund_invoice_from_tranches( + env.clone(), + token.clone(), + 1, + 1000, + ); + }); + + // Default with zero collateral (1000 shortfall) + env.as_contract(&contract_id, || { + TrancheContract::allocate_loss( + env.clone(), + token.clone(), + 1, + 1000, + ); + }); + + env.as_contract(&contract_id, || { + let pool = TrancheContract::get_pool(env.clone(), token.clone()); + + // Junior should be wiped out (absorbed 200 from this invoice, but had only 500 total) + assert!(pool.junior.losses >= 200); + // Senior should take the remaining loss + assert!(pool.senior.losses > 0); + + // Total losses should equal shortfall + assert_eq!(pool.senior.losses + pool.junior.losses, 1000); + }); +} diff --git a/frontend/app/admin/exchange-rates/page.test.tsx b/frontend/app/admin/exchange-rates/page.test.tsx index 6422005e..5d83f5d3 100644 --- a/frontend/app/admin/exchange-rates/page.test.tsx +++ b/frontend/app/admin/exchange-rates/page.test.tsx @@ -34,7 +34,9 @@ jest.mock('react-hot-toast', () => ({ })); jest.mock('@/components/Skeleton', () => ({ - Skeleton: ({ className }: { className: string }) =>
, + Skeleton: ({ className }: { className: string }) => ( +
+ ), })); describe('AdminExchangeRatesPage', () => { @@ -65,7 +67,9 @@ describe('AdminExchangeRatesPage', () => { await user.click(screen.getByRole('button', { name: /confirm & submit/i })); - await waitFor(() => expect(mockBuildSetExchangeRateTx).toHaveBeenCalledWith('GABC123', 'USDC', 10_800)); + await waitFor(() => + expect(mockBuildSetExchangeRateTx).toHaveBeenCalledWith('GABC123', 'USDC', 10_800), + ); expect(mockSubmitTx).toHaveBeenCalledWith('signed-xdr'); }); }); diff --git a/frontend/app/admin/tranches/page.tsx b/frontend/app/admin/tranches/page.tsx new file mode 100644 index 00000000..44080722 --- /dev/null +++ b/frontend/app/admin/tranches/page.tsx @@ -0,0 +1,375 @@ +'use client'; + +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import { TrancheConfig } from '@/../sdk/src/generated/tranche'; +import { buildSetTrancheConfigTx } from '@/lib/contracts'; +import { submitTx } from '@/lib/stellar'; +import { useStore } from '@/lib/store'; + +interface TokenTrancheConfig { + token: string; + enabled: boolean; + config: TrancheConfig; + seniorShareToken: string; + juniorShareToken: string; +} + +export default function TrancheConfigPage() { + const { wallet } = useStore(); + const [selectedToken, setSelectedToken] = useState('USDC'); + const [saving, setSaving] = useState(false); + const [configs, setConfigs] = useState([ + { + token: 'USDC', + enabled: true, + config: { + senior_target_yield_bps: 1000, + senior_advance_rate_bps: 8000, + junior_first_loss_bps: 10000, + }, + seniorShareToken: 'sASTR-SR-USDC', + juniorShareToken: 'sASTR-JR-USDC', + }, + { + token: 'USDT', + enabled: false, + config: { + senior_target_yield_bps: 1000, + senior_advance_rate_bps: 8000, + junior_first_loss_bps: 10000, + }, + seniorShareToken: '', + juniorShareToken: '', + }, + ]); + + const [editingConfig, setEditingConfig] = useState(null); + const [showSaveModal, setShowSaveModal] = useState(false); + + const handleEnableTranche = (token: string) => { + setConfigs(configs.map((c) => (c.token === token ? { ...c, enabled: true } : c))); + }; + + const handleDisableTranche = (token: string) => { + setConfigs(configs.map((c) => (c.token === token ? { ...c, enabled: false } : c))); + }; + + const handleEditConfig = (token: string) => { + const config = configs.find((c) => c.token === token); + if (config) { + setEditingConfig({ ...config.config }); + setSelectedToken(token); + } + }; + + const handleSaveConfig = async () => { + if (!editingConfig) return; + if (!wallet.connected || !wallet.address) { + toast.error('Connect your admin wallet to save on-chain.'); + return; + } + setSaving(true); + try { + const xdr = await buildSetTrancheConfigTx( + wallet.address, + selectedToken, + editingConfig.senior_target_yield_bps, + editingConfig.senior_advance_rate_bps, + editingConfig.junior_first_loss_bps, + ); + const freighter = await import('@stellar/freighter-api'); + const { signedTxXdr, error: signError } = await freighter.signTransaction(xdr, { + networkPassphrase: 'Test SDF Network ; September 2015', + address: wallet.address, + }); + if (signError) throw new Error(signError.message); + await submitTx(signedTxXdr); + + // Reflect the persisted config in local state after the on-chain write. + setConfigs( + configs.map((c) => + c.token === selectedToken ? { ...c, config: { ...editingConfig } } : c, + ), + ); + setEditingConfig(null); + setShowSaveModal(false); + toast.success(`${selectedToken} tranche configuration saved on-chain.`); + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Failed to save configuration.'); + } finally { + setSaving(false); + } + }; + + const currentConfig = configs.find((c) => c.token === selectedToken); + + return ( +
+
+ {/* Header */} +
+

Tranche Configuration

+

+ Configure senior/junior tranches for each supported token +

+
+ + {/* Token Selector */} +
+ + +
+ +
+ {/* Configuration Panel */} +
+
+

{selectedToken} Configuration

+ + {currentConfig?.enabled ? 'Enabled' : 'Disabled'} + +
+ + {currentConfig?.enabled ? ( +
+ {/* Senior Target Yield */} +
+ + + setEditingConfig((prev) => ({ + ...prev!, + senior_target_yield_bps: Number(e.target.value), + })) + } + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + disabled={!editingConfig} + /> +

+ {(currentConfig.config.senior_target_yield_bps / 100).toFixed(1)}% target annual + yield +

+
+ + {/* Senior Advance Rate */} +
+ + + setEditingConfig((prev) => ({ + ...prev!, + senior_advance_rate_bps: Number(e.target.value), + })) + } + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + disabled={!editingConfig} + /> +

+ {(currentConfig.config.senior_advance_rate_bps / 100).toFixed(0)}% maximum + senior share of pool +

+
+ + {/* Junior First Loss */} +
+ + + setEditingConfig((prev) => ({ + ...prev!, + junior_first_loss_bps: Number(e.target.value), + })) + } + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + disabled={!editingConfig} + /> +

+ {(currentConfig.config.junior_first_loss_bps / 100).toFixed(0)}% of losses + junior absorbs before senior +

+
+ + {/* Share Tokens */} +
+
+ + +
+
+ + +
+
+ + {/* Actions */} +
+ {editingConfig ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ) : ( +
+

Tranche is not enabled for {selectedToken}

+ +
+ )} +
+ + {/* All Tokens Overview */} +
+

All Token Configurations

+
+ {configs.map((config) => ( +
+
+
+

{config.token}

+
+ {config.enabled ? ( + <> + + Yield: {(config.config.senior_target_yield_bps / 100).toFixed(1)}% + + + + Advance: {(config.config.senior_advance_rate_bps / 100).toFixed(0)}% + + + ) : ( + 'Not enabled' + )} +
+
+ + {config.enabled ? 'Active' : 'Inactive'} + +
+
+ ))} +
+
+
+ + {/* Risk Guidelines */} +
+

Configuration Guidelines

+
+

+ Senior Target Yield: Should reflect market conditions and risk + profile. Too high may make senior uncompetitive; too low may reduce junior + participation. +

+

+ Senior Advance Rate: Determines how much senior capital can be + deployed relative to junior. Higher rates increase senior exposure but require more + junior capital for protection. +

+

+ Junior First Loss: Typically set to 10000 (100%) to ensure junior + absorbs all losses before senior. Lower values share risk between tranches but reduce + senior protection. +

+
+
+
+
+ ); +} diff --git a/frontend/app/admin/waterfall-simulation/page.tsx b/frontend/app/admin/waterfall-simulation/page.tsx new file mode 100644 index 00000000..fc4ca898 --- /dev/null +++ b/frontend/app/admin/waterfall-simulation/page.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { useState } from 'react'; +import { TrancheClass } from '@/../sdk/src/generated/tranche'; + +interface WaterfallResult { + seniorAmount: bigint; + juniorAmount: bigint; + seniorCap: bigint; + elapsedYield: bigint; +} + +export default function WaterfallSimulationPage() { + const [invoiceId, setInvoiceId] = useState(1); + const [totalDue, setTotalDue] = useState(1000); + const [seniorPrincipal, setSeniorPrincipal] = useState(800); + const [juniorPrincipal, setJuniorPrincipal] = useState(200); + const [seniorTargetYieldBps, setSeniorTargetYieldBps] = useState(1000); + const [elapsedSecs, setElapsedSecs] = useState(30 * 24 * 60 * 60); // 30 days + const [result, setResult] = useState(null); + + const simulateWaterfall = () => { + // Calculate senior cap: principal + time-proportional yield + const seniorCap = + seniorPrincipal + + (seniorPrincipal * seniorTargetYieldBps * elapsedSecs) / (365 * 24 * 60 * 60 * 10000); + + // Calculate elapsed yield portion + const elapsedYield = + (seniorPrincipal * seniorTargetYieldBps * elapsedSecs) / (365 * 24 * 60 * 60 * 10000); + + // Senior gets min(totalDue, cap) + const seniorAmount = Math.min(totalDue, seniorCap); + + // Junior gets remainder + const juniorAmount = Math.max(0, totalDue - seniorAmount); + + setResult({ + seniorAmount: BigInt(Math.round(seniorAmount * 1e7)), // Convert to contract units + juniorAmount: BigInt(Math.round(juniorAmount * 1e7)), + seniorCap: BigInt(Math.round(seniorCap * 1e7)), + elapsedYield: BigInt(Math.round(elapsedYield * 1e7)), + }); + }; + + const simulateLossAllocation = () => { + const shortfall = Math.max(0, seniorPrincipal + juniorPrincipal - totalDue); + const juniorRemaining = juniorPrincipal; + + const juniorLoss = Math.min(shortfall, juniorRemaining); + const seniorLoss = Math.max(0, shortfall - juniorRemaining); + + return { juniorLoss, seniorLoss, shortfall }; + }; + + const lossResult = simulateLossAllocation(); + + return ( +
+
+ {/* Header */} +
+

Waterfall Simulation

+

+ Simulate waterfall repayment and loss allocation for hypothetical scenarios +

+
+ +
+ {/* Input Parameters */} +
+

Simulation Parameters

+ +
+ {/* Invoice ID */} +
+ + setInvoiceId(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +
+ + {/* Total Due */} +
+ + setTotalDue(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + placeholder="Enter repayment amount" + /> +

Principal + interest being repaid

+
+ + {/* Senior Principal */} +
+ + setSeniorPrincipal(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +

Amount funded by senior tranche

+
+ + {/* Junior Principal */} +
+ + setJuniorPrincipal(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +

Amount funded by junior tranche

+
+ + {/* Senior Target Yield */} +
+ + setSeniorTargetYieldBps(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +

+ {(seniorTargetYieldBps / 100).toFixed(1)}% annual target yield +

+
+ + {/* Elapsed Time */} +
+ + setElapsedSecs(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + /> +

+ {(elapsedSecs / (24 * 60 * 60)).toFixed(1)} days elapsed +

+
+ + +
+
+ + {/* Results */} +
+ {/* Waterfall Results */} +
+

Waterfall Distribution

+ + {result ? ( +
+ {/* Senior Payout */} +
+

Senior Tranche

+
+
+ Payout + + ${(Number(result.seniorAmount) / 1e7).toLocaleString()} + +
+
+ Cap + + ${(Number(result.seniorCap) / 1e7).toLocaleString()} + +
+
+ Elapsed Yield + + ${(Number(result.elapsedYield) / 1e7).toLocaleString()} + +
+
+ Principal Return + + ${seniorPrincipal.toLocaleString()} + +
+
+
+ + {/* Junior Payout */} +
+

Junior Tranche

+
+
+ Payout + + ${(Number(result.juniorAmount) / 1e7).toLocaleString()} + +
+
+ Principal Return + + $ + {Math.min( + juniorPrincipal, + Number(result.juniorAmount) / 1e7, + ).toLocaleString()} + +
+
+ Residual Yield + + $ + {Math.max( + 0, + Number(result.juniorAmount) / 1e7 - juniorPrincipal, + ).toLocaleString()} + +
+
+
+ + {/* Summary */} +
+
+
+ Total Distributed + + $ + {( + (Number(result.seniorAmount) + Number(result.juniorAmount)) / + 1e7 + ).toLocaleString()} + +
+
+ Total Due + + ${totalDue.toLocaleString()} + +
+
+ Remaining + + $ + {Math.max( + 0, + totalDue - + (Number(result.seniorAmount) + Number(result.juniorAmount)) / 1e7, + ).toLocaleString()} + +
+
+
+
+ ) : ( +
Run simulation to see results
+ )} +
+ + {/* Loss Allocation Results */} +
+

+ Loss Allocation (if Default) +

+ +
+ {lossResult.shortfall > 0 ? ( + <> +
+

Shortfall

+

+ ${lossResult.shortfall.toLocaleString()} +

+

+ Principal owed: ${(seniorPrincipal + juniorPrincipal).toLocaleString()} +

+
+ +
+

Junior Absorbs First

+
+
+ Junior Loss + + ${lossResult.juniorLoss.toLocaleString()} + +
+
+ Junior Remaining + + ${(juniorPrincipal - lossResult.juniorLoss).toLocaleString()} + +
+
+
+ + {lossResult.seniorLoss > 0 && ( +
+

Senior Takes Remaining

+
+
+ Senior Loss + + ${lossResult.seniorLoss.toLocaleString()} + +
+
+ Senior Remaining + + ${(seniorPrincipal - lossResult.seniorLoss).toLocaleString()} + +
+
+
+ )} + + {lossResult.seniorLoss === 0 && ( +
+

+ Senior Protected: Junior capital absorbed the entire + shortfall. Senior investors remain whole. +

+
+ )} + + ) : ( +
+

+ No Default: Repayment covers full principal. No loss + allocation needed. +

+
+ )} +
+
+
+
+ + {/* Scenario Presets */} +
+

Common Scenarios

+
+ + + +
+
+
+
+ ); +} diff --git a/frontend/app/invest/co-funding/page.tsx b/frontend/app/invest/co-funding/page.tsx index d3688b27..dbbeeb4d 100644 --- a/frontend/app/invest/co-funding/page.tsx +++ b/frontend/app/invest/co-funding/page.tsx @@ -14,7 +14,7 @@ import { buildTransferCoFundShareTx, } from '@/lib/contracts'; import { useCoFundingRounds, useCoFundingPositions } from '@/lib/cache'; -import { useTrackTransaction } from '@/hooks/useTrackTransaction'; +import { UseTrackTransaction } from '@/hooks/useTrackTransaction'; const STATUS_STYLES: Record = { Open: 'bg-blue-500/20 text-blue-400 border-blue-500/30', @@ -26,7 +26,7 @@ const STATUS_STYLES: Record = { export default function CoFundingPage() { const { wallet } = useStore(); const [txLoading, setTxLoading] = useState(false); - const trackedSubmit = useTrackTransaction('Co-Funding'); + const trackedSubmit = UseTrackTransaction('Co-Funding'); const [commitAmounts, setCommitAmounts] = useState>({}); @@ -46,7 +46,7 @@ export default function CoFundingPage() { address: wallet.address!, }); if (signError) throw new Error(signError.message || 'Signing rejected.'); - const submitter = label ? useTrackTransaction(label) : trackedSubmit; + const submitter = label ? UseTrackTransaction(label) : trackedSubmit; await submitter(signedTxXdr); } @@ -135,10 +135,9 @@ export default function CoFundingPage() {

Co-Funding Rounds

- Commit capital toward a specific invoice alongside other investors. Every co-funder - ranks pari passu and owns a proportional slice of that invoice's principal and - interest — separate from the general pool position, and tradeable once the round is - filled. + Commit capital toward a specific invoice alongside other investors. Every co-funder ranks + pari passu and owns a proportional slice of that invoice's principal and interest — + separate from the general pool position, and tradeable once the round is filled.

diff --git a/frontend/app/invest/page.tsx b/frontend/app/invest/page.tsx index a66a628d..52e55305 100644 --- a/frontend/app/invest/page.tsx +++ b/frontend/app/invest/page.tsx @@ -25,7 +25,16 @@ import { getCurrentRate, } from '@/lib/contracts'; import { parseStellarAddress } from '@/lib/types'; -import { toStroops, formatUSDC, stablecoinLabel, USDC_TOKEN_ID, POOL_CONTRACT_ID, nativeToScVal, Address, xdr } from '@/lib/stellar'; +import { + toStroops, + formatUSDC, + stablecoinLabel, + USDC_TOKEN_ID, + POOL_CONTRACT_ID, + nativeToScVal, + Address, + xdr, +} from '@/lib/stellar'; import type { PoolTokenTotals, RateModelConfig } from '@/lib/types'; import { simulateContractCall } from '@/lib/simulateFee'; import { useTransactionSimulation } from '@/hooks/useTransactionSimulation'; @@ -173,11 +182,17 @@ export default function InvestPage() { amount !== '' && toStroops(parseFloat(amount)) > remainingTokenCapacity; - const showDepositWarning = - mode === 'deposit' && kycRequired && !kycApproved; + const showDepositWarning = mode === 'deposit' && kycRequired && !kycApproved; const simulateDeposit = useCallback(() => { - if (!wallet.address || !selectedToken || !amount || parseFloat(amount) <= 0 || depositExceedsCap) return null; + if ( + !wallet.address || + !selectedToken || + !amount || + parseFloat(amount) <= 0 || + depositExceedsCap + ) + return null; if (mode !== 'deposit') return null; const stroops = toStroops(parseFloat(amount)); return simulateContractCall( @@ -194,7 +209,13 @@ export default function InvestPage() { const simulation = useTransactionSimulation( simulateDeposit, - !!wallet.address && !!selectedToken && !!amount && parseFloat(amount) > 0 && mode === 'deposit' && !depositExceedsCap && !showDepositWarning, + !!wallet.address && + !!selectedToken && + !!amount && + parseFloat(amount) > 0 && + mode === 'deposit' && + !depositExceedsCap && + !showDepositWarning, ); async function submitTransaction() { @@ -423,7 +444,8 @@ export default function InvestPage() { })}

)} - {mode === 'withdraw' && tokenTotals && ( + {mode === 'withdraw' && + tokenTotals && // #782: pool_value minus deployed capital is the actual // ceiling on any single withdrawal — a user's own share // balance can exceed this when most of the pool is @@ -450,8 +472,7 @@ export default function InvestPage() { })}

); - })() - )} + })()} {mode === 'deposit' && tokenDepositCap > 0n && tokenTotals && (
diff --git a/frontend/app/invest/tranches/page.tsx b/frontend/app/invest/tranches/page.tsx new file mode 100644 index 00000000..36aac1dc --- /dev/null +++ b/frontend/app/invest/tranches/page.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { TrancheClass, TranchePool } from '@/../sdk/src/generated/tranche'; +import { getTranchePool, buildTrancheDepositTx } from '@/lib/contracts'; +import { submitTx, toStroops } from '@/lib/stellar'; +import { useStore } from '@/lib/store'; +import toast from 'react-hot-toast'; + +const INDEXER_URL = process.env.NEXT_PUBLIC_INDEXER_URL || 'http://localhost:3001'; +const TOKENS = ['USDC', 'USDT', 'EURC']; + +interface TrancheApyData { + senior: number; // realizedApyPct + junior: number; +} + +async function fetchTrailingApy(token: string): Promise { + const res = await fetch(`${INDEXER_URL}/tranches/${encodeURIComponent(token)}/apy`); + if (!res.ok) throw new Error('indexer unavailable'); + const data = await res.json(); + return { + senior: data.senior?.realizedApyPct ?? 0, + junior: data.junior?.realizedApyPct ?? 0, + }; +} + +interface TrancheCardProps { + token: string; + trancheClass: TrancheClass; + pool: TranchePool; + targetApy: number; + trailingApy: number; + onDeposit: () => void; +} + +function TrancheCard({ + token, + trancheClass, + pool, + targetApy, + trailingApy, + onDeposit, +}: TrancheCardProps) { + const isSenior = trancheClass === TrancheClass.Senior; + const config = pool.config; + const accounting = isSenior ? pool.senior : pool.junior; + const deposited = Number(accounting.deposited) / 1e7; + const available = Number(accounting.available) / 1e7; + const deployed = Number(accounting.deployed) / 1e7; + const earned = Number(accounting.earned) / 1e7; + const losses = Number(accounting.losses) / 1e7; + + return ( +
+
+
+

+ {isSenior ? 'Senior' : 'Junior'} Tranche +

+

{token}

+
+ + {isSenior ? 'Lower Risk' : 'Higher Risk'} + +
+ +
+
+

Target APY

+

+ {isSenior ? `${targetApy.toFixed(1)}%` : 'Residual'} +

+
+
+

Trailing Realized APY

+

= targetApy ? 'text-green-600' : 'text-orange-600'}`} + > + {trailingApy.toFixed(1)}% +

+
+
+ +
+
+ Total Deposited + ${deposited.toLocaleString()} +
+
+ Available to Fund + ${available.toLocaleString()} +
+
+ Deployed in Invoices + ${deployed.toLocaleString()} +
+
+ Total Earned + ${earned.toLocaleString()} +
+ {losses > 0 && ( +
+ Losses Absorbed + ${losses.toLocaleString()} +
+ )} +
+ +
+

Risk Metrics

+ {isSenior ? ( +
+
+ Advance Rate Cap + {(config.senior_advance_rate_bps / 100).toFixed(0)}% +
+
+ Junior Buffer + ${(Number(pool.junior.deposited) / 1e7).toLocaleString()} +
+
+ ) : ( +
+
+ First Loss Position + {(config.junior_first_loss_bps / 100).toFixed(0)}% +
+
+ Residual Upside + Unlimited +
+
+ )} +
+ + +
+ ); +} + +function RiskExplainer() { + return ( +
+

Understanding Tranche Risk

+
+
+

Senior Tranche (Lower Risk)

+

+ Senior investors receive priority repayment with a capped return. Your principal is + protected by junior capital that absorbs losses first. You earn a fixed target yield but + don't participate in excess upside. +

+
+
+

Junior Tranche (Higher Risk)

+

+ Junior investors absorb losses first, protecting senior capital. In exchange, you + receive all residual returns after senior obligations are met. Potential for higher + returns, but higher risk of loss. +

+
+
+

Waterfall Repayment

+

+ When invoices are repaid, funds flow first to senior investors up to their capped + return. Any remaining funds flow to junior investors. In defaults, junior capital + absorbs losses before senior investors are affected. +

+
+
+
+ ); +} + +export default function TranchesPage() { + const { wallet } = useStore(); + const [selectedToken, setSelectedToken] = useState('USDC'); + const [depositAmount, setDepositAmount] = useState(0); + const [showDepositModal, setShowDepositModal] = useState(false); + const [selectedTranche, setSelectedTranche] = useState(null); + const [pool, setPool] = useState(null); + const [apy, setApy] = useState({ senior: 0, junior: 0 }); + const [loading, setLoading] = useState(true); + const [depositing, setDepositing] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const [poolData, apyData] = await Promise.all([ + getTranchePool(selectedToken).catch(() => null), + fetchTrailingApy(selectedToken).catch(() => ({ senior: 0, junior: 0 })), + ]); + if (poolData) setPool(poolData); + setApy(apyData); + } finally { + setLoading(false); + } + }, [selectedToken]); + + useEffect(() => { + load(); + }, [load]); + + const handleDeposit = (tranche: TrancheClass) => { + setSelectedTranche(tranche); + setShowDepositModal(true); + }; + + const confirmDeposit = async () => { + if (!wallet.connected || !wallet.address || selectedTranche === null || depositAmount <= 0) + return; + setDepositing(true); + try { + const amount = toStroops(depositAmount); + const xdr = await buildTrancheDepositTx( + wallet.address, + selectedToken, + selectedTranche, + amount, + ); + const freighter = await import('@stellar/freighter-api'); + const { signedTxXdr, error: signError } = await freighter.signTransaction(xdr, { + networkPassphrase: 'Test SDF Network ; September 2015', + address: wallet.address, + }); + if (signError) throw new Error(signError.message); + await submitTx(signedTxXdr); + toast.success( + `Deposited ${depositAmount} ${selectedToken} to ${selectedTranche === TrancheClass.Senior ? 'Senior' : 'Junior'} tranche`, + ); + setShowDepositModal(false); + setDepositAmount(0); + await load(); + } catch (e: unknown) { + toast.error(e instanceof Error ? e.message : 'Deposit failed'); + } finally { + setDepositing(false); + } + }; + + const seniorTargetApy = pool ? pool.config.senior_target_yield_bps / 100 : 10; + + return ( +
+
+
+

Tranche Investments

+

+ Choose your risk profile with senior and junior tranches +

+
+ +
+ + +
+ +
+ +
+ + {loading && ( +
+ {[0, 1].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!loading && pool && ( +
+ handleDeposit(TrancheClass.Senior)} + /> + handleDeposit(TrancheClass.Junior)} + /> +
+ )} + + {showDepositModal && ( +
+
+

+ Deposit to {selectedTranche === TrancheClass.Senior ? 'Senior' : 'Junior'} Tranche +

+ {!wallet.connected && ( +

Connect your wallet to deposit.

+ )} +
+ + setDepositAmount(Number(e.target.value))} + className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + placeholder="Enter amount" + /> +
+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/app/invoice/[id]/page.tsx b/frontend/app/invoice/[id]/page.tsx index 9ffc2a6c..2a4219f9 100644 --- a/frontend/app/invoice/[id]/page.tsx +++ b/frontend/app/invoice/[id]/page.tsx @@ -59,6 +59,7 @@ import type { CoFundingRound, FullCreditScore, } from '@/lib/types'; +import BorrowerCreditBadge from '@/components/BorrowerCreditBadge'; type InvoiceEventKind = 'created' | 'funded' | 'paid' | 'defaulted' | 'repaid'; @@ -366,7 +367,7 @@ export default function InvoiceDetailPage() { const repaySimulation = useTransactionSimulation( simulateRepay, isOwner && - metadata.status === 'Funded' && + metadata?.status === 'Funded' && !!fundedInvoice && !fullyRepaid && !!wallet.address && diff --git a/frontend/app/portfolio/page.tsx b/frontend/app/portfolio/page.tsx index a3f20406..98c2e11d 100644 --- a/frontend/app/portfolio/page.tsx +++ b/frontend/app/portfolio/page.tsx @@ -5,6 +5,7 @@ import toast from 'react-hot-toast'; import { useStore } from '@/lib/store'; import { StatCardSkeleton, Skeleton } from '@/components/Skeleton'; import { LiquidityForecastChart } from '@/components/analytics/LiquidityForecastChart'; +import { TrancheClass } from '@/../sdk/src/generated/tranche'; import { getInvestorPosition, getPoolConfig, @@ -17,6 +18,7 @@ import { buildCancelWithdrawalRequestTx, buildDrainWithdrawalQueueTx, submitTx, + getTrancheInvestorPosition, } from '@/lib/contracts'; import { formatUSDC, stablecoinLabel, toStroops } from '@/lib/stellar'; import type { PoolTokenTotals, WaitEstimate, LiquidityForecastPoint } from '@/lib/types'; @@ -29,6 +31,13 @@ interface PortfolioSnapshot { depositCount: number; } +interface TranchePositionData { + senior: { deposited: bigint; earned: bigint; losses: bigint }; + junior: { deposited: bigint; earned: bigint; losses: bigint }; + seniorApyPct: number; + juniorApyPct: number; +} + interface TokenRow { token: string; totals: PoolTokenTotals; @@ -37,6 +46,55 @@ interface TokenRow { forecast: LiquidityForecastPoint[]; /** Exchange rate in bps (10_000 = 1:1 USD) */ rateBps: number; + /** #862: per-investor senior/junior tranche positions, null if none/unavailable */ + tranche: TranchePositionData | null; +} + +const INDEXER_URL = process.env.NEXT_PUBLIC_INDEXER_URL || 'http://localhost:3001'; + +async function fetchTranchePositions( + investor: string, + token: string, +): Promise { + try { + const [senior, junior] = await Promise.all([ + getTrancheInvestorPosition(investor, token, TrancheClass.Senior).catch(() => null), + getTrancheInvestorPosition(investor, token, TrancheClass.Junior).catch(() => null), + ]); + if (!senior && !junior) return null; + // Only surface the section if the investor actually has tranche exposure. + if ((senior?.deposited ?? 0n) === 0n && (junior?.deposited ?? 0n) === 0n) return null; + + let seniorApyPct = 0; + let juniorApyPct = 0; + try { + const res = await fetch(`${INDEXER_URL}/tranches/${encodeURIComponent(token)}/apy`); + if (res.ok) { + const data = await res.json(); + seniorApyPct = data.senior?.realizedApyPct ?? 0; + juniorApyPct = data.junior?.realizedApyPct ?? 0; + } + } catch { + // indexer optional — leave APY at 0 if unavailable + } + + return { + senior: { + deposited: senior?.deposited ?? 0n, + earned: senior?.earned ?? 0n, + losses: senior?.losses ?? 0n, + }, + junior: { + deposited: junior?.deposited ?? 0n, + earned: junior?.earned ?? 0n, + losses: junior?.losses ?? 0n, + }, + seniorApyPct, + juniorApyPct, + }; + } catch { + return null; + } } /** Render a seconds duration as a short human string, e.g. "3h", "2d", "~1mo". */ @@ -195,12 +253,13 @@ export default function PortfolioPage() { const rowData: TokenRow[] = await Promise.all( tokens.map(async (token) => { - const [totals, rawPos, rateBps, waitEstimate, forecast] = await Promise.all([ + const [totals, rawPos, rateBps, waitEstimate, forecast, tranche] = await Promise.all([ getPoolTokenTotals(token), getInvestorPosition(wallet.address!, token), getExchangeRate(token).catch(() => 10_000), estimateWithdrawalWait(wallet.address!, token).catch(() => null), getLiquidityForecast(token, 30).catch(() => []), + fetchTranchePositions(wallet.address!, token), ]); const position: PortfolioSnapshot | null = rawPos @@ -213,7 +272,7 @@ export default function PortfolioPage() { } : null; - return { token, totals, position, waitEstimate, forecast, rateBps }; + return { token, totals, position, waitEstimate, forecast, rateBps, tranche }; }), ); @@ -528,8 +587,8 @@ export default function PortfolioPage() { {/* Per-token positions (collapsible when > 1 token) */}
-

Token Positions

- {rows.map(({ token, position, totals, waitEstimate, forecast, rateBps }) => { +

Token Positions

+ {rows.map(({ token, position, totals, waitEstimate, forecast, rateBps, tranche }) => { const isCollapsed = collapsed[token] ?? false; const usdcDeposited = position ? toUsdcEquiv(position.totalDeposited, rateBps) : 0n; const dueDate = waitEstimate?.nearestInvoiceDueDate @@ -699,6 +758,101 @@ export default function PortfolioPage() { />
)} + + {/* #862: Tranche Positions Section — real per-investor + positions from the tranche contract, with trailing + realized APY sourced from the indexer. Hidden when the + investor has no tranche exposure for this token. */} + {tranche && ( +
+

Tranche Positions

+
+ {/* Senior Tranche Card */} +
+
+ + Senior Tranche + + + Lower Risk + +
+
+
+ Deposited + + {formatUSDC(tranche.senior.deposited)} + +
+
+ Earned + + {formatUSDC(tranche.senior.earned)} + +
+
+ Losses + + {formatUSDC(tranche.senior.losses)} + +
+
+ Trailing APY + + {tranche.seniorApyPct.toFixed(1)}% + +
+
+
+ + {/* Junior Tranche Card */} +
+
+ + Junior Tranche + + + Higher Risk + +
+
+
+ Deposited + + {formatUSDC(tranche.junior.deposited)} + +
+
+ Earned + + {formatUSDC(tranche.junior.earned)} + +
+
+ Losses + + {formatUSDC(tranche.junior.losses)} + +
+
+ Trailing APY + + {tranche.juniorApyPct.toFixed(1)}% + +
+
+
+
+ +
+ )} )}
diff --git a/frontend/components/InvoiceCard.tsx b/frontend/components/InvoiceCard.tsx index 65c7e36c..a05c3213 100644 --- a/frontend/components/InvoiceCard.tsx +++ b/frontend/components/InvoiceCard.tsx @@ -109,7 +109,9 @@ export default function InvoiceCard({ id, metadata, fundedAmount }: Props) {
Co-funding progress - {fundedPercent.toFixed(1)}% + + {fundedPercent.toFixed(1)}% +
s.addTrackedTransaction); const updateTracked = useStore((s) => s.updateTrackedTransaction); diff --git a/frontend/lib/contracts.ts b/frontend/lib/contracts.ts index db6ba7dc..0cb3745d 100644 --- a/frontend/lib/contracts.ts +++ b/frontend/lib/contracts.ts @@ -9,6 +9,7 @@ import { ORACLE_REGISTRY_CONTRACT_ID, COMPLIANCE_CONTRACT_ID, REFERRAL_CONTRACT_ID, + TRANCHE_CONTRACT_ID, ACCESS_CONTROL_CONTRACT_ID, NETWORK, simulateTx, @@ -106,14 +107,18 @@ if (REFERRAL_CONTRACT_ID) { const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true'; const MOCK_API_URL = process.env.NEXT_PUBLIC_MOCK_API_URL ?? 'http://localhost:4000'; -type RpcAccount = Awaited>; +type RpcAccount = Awaited> & AccountWithBalances; type RpcBuiltTransaction = Parameters[0]; +interface AccountWithBalances { + balances: Array<{ asset_type: string; balance: string }>; +} + function getRpcAccount(address: string): Promise { - return rpcExecute((server) => server.getAccount(address)); + return rpcExecute((server) => server.getAccount(address) as Promise); } -function getNativeBalanceStroops(account: Pick | undefined): bigint { +function getNativeBalanceStroops(account: AccountWithBalances | undefined): bigint { if (!account?.balances) return 0n; const nativeBalance = account.balances.find((balance) => balance.asset_type === 'native'); if (!nativeBalance?.balance) return 0n; @@ -121,7 +126,7 @@ function getNativeBalanceStroops(account: Pick | undefin } function ensureSufficientNativeBalance( - account: Pick, + account: AccountWithBalances, requiredStroops = BigInt(BASE_FEE), ) { if (getNativeBalanceStroops(account) < requiredStroops) { @@ -2665,267 +2670,121 @@ export async function buildRegisterReferralTx(referee: string, referrer: string) return StellarRpc.assembleTransaction(tx, sim).build().toXDR(); } -// ---- #864: role-based multisig access control ---- +// ── #862: Tranche helpers ───────────────────────────────────────────────────── -const SIMULATION_SOURCE = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; +import { TrancheClass } from '@/../sdk/src/generated/tranche'; +import type { TranchePool, TrancheConfig } from '@/../sdk/src/generated/tranche'; -function roleToScVal(role: Role): xdr.ScVal { - return xdr.ScVal.scvVec([nativeToScVal(role, { type: 'symbol' })]); -} +export type { TranchePool, TrancheConfig }; +export { TrancheClass }; -/** - * Encodes an `ActionPayload` (mirrors contracts/access_control/src/lib.rs's - * `ActionPayload` enum) the same way `attestorTypeToScVal` above encodes a - * unit-variant enum, extended for tuple-variant payloads: a Vec whose first - * element is the variant name (Symbol) followed by its fields in order. - */ -function actionPayloadToScVal(action: ActionPayload): xdr.ScVal { - const tag = nativeToScVal(action.tag, { type: 'symbol' }); - switch (action.tag) { - case 'SetPaused': - case 'SetKycRequired': - return xdr.ScVal.scvVec([tag, nativeToScVal(action.values[0], { type: 'bool' })]); - case 'SetYield': - case 'SetMaxUtilization': - return xdr.ScVal.scvVec([tag, nativeToScVal(action.values[0], { type: 'u32' })]); - case 'SetTreasury': - case 'SetOracleContract': - case 'SetOracle': - case 'AddKeeper': - return xdr.ScVal.scvVec([tag, new Address(action.values[0]).toScVal()]); - case 'WithdrawRevenue': - return xdr.ScVal.scvVec([ - tag, - new Address(action.values[0]).toScVal(), - nativeToScVal(action.values[1], { type: 'i128' }), - ]); - case 'SetInvestorKyc': - return xdr.ScVal.scvVec([ - tag, - new Address(action.values[0]).toScVal(), - nativeToScVal(action.values[1], { type: 'bool' }), - ]); - case 'RegisterDebtor': - return xdr.ScVal.scvVec([ - tag, - nativeToScVal(action.values[0], { type: 'string' }), - nativeToScVal(action.values[1], { type: 'string' }), - nativeToScVal(action.values[2], { type: 'i128' }), - ]); - case 'DeactivateDebtor': - return xdr.ScVal.scvVec([tag, nativeToScVal(action.values[0], { type: 'string' })]); - case 'SetLateThreshold': - return xdr.ScVal.scvVec([tag, nativeToScVal(action.values[0], { type: 'i64' })]); - case 'SetScoreThresholds': - return xdr.ScVal.scvVec([ - tag, - nativeToScVal(action.values[0], { type: 'u32' }), - nativeToScVal(action.values[1], { type: 'u32' }), - nativeToScVal(action.values[2], { type: 'u32' }), - nativeToScVal(action.values[3], { type: 'u32' }), - ]); - case 'RegisterAttestor': - return xdr.ScVal.scvVec([ - tag, - new Address(action.values[0]).toScVal(), - nativeToScVal(action.values[1], { type: 'u32' }), - nativeToScVal(action.values[2], { type: 'u32' }), - ]); - case 'AddSigner': - case 'RemoveSigner': - return xdr.ScVal.scvVec([ - tag, - roleToScVal(action.values[0]), - new Address(action.values[1]).toScVal(), - ]); - case 'SetThreshold': - return xdr.ScVal.scvVec([ - tag, - roleToScVal(action.values[0]), - nativeToScVal(action.values[1], { type: 'u32' }), - ]); - } -} +const DUMMY_CALLER = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN'; -/** - * `scValToNative` decodes our `[tag, ...fields]` vec encoding of an - * `ActionPayload` variant into a flat native array (e.g. `['SetYield', 650]`), - * not the `{ tag, values }` shape `ActionPayload` expects — reshape it here. - */ -function actionPayloadFromNative(raw: unknown): ActionPayload { - const [tag, ...values] = raw as [ActionPayload['tag'], ...unknown[]]; - return { tag, values } as ActionPayload; +function trancheClassToScVal(tc: TrancheClass): xdr.ScVal { + const name = tc === TrancheClass.Senior ? 'Senior' : 'Junior'; + return xdr.ScVal.scvVec([nativeToScVal(name, { type: 'symbol' })]); } -function proposalFromScVal(raw: Record): Proposal { +function accountingFromRaw(r: Record) { return { - role: enumTagFromNative(raw.role), - target: raw.target as StellarAddress, - action: actionPayloadFromNative(raw.action), - proposer: raw.proposer as StellarAddress, - approvals: (raw.approvals as StellarAddress[]) ?? [], - createdAt: Number(raw.created_at ?? 0), - expiresAt: Number(raw.expires_at ?? 0), - status: enumTagFromNative(raw.status) as unknown as Proposal['status'], + deposited: BigInt(String(r.deposited ?? 0)), + available: BigInt(String(r.available ?? 0)), + deployed: BigInt(String(r.deployed ?? 0)), + earned: BigInt(String(r.earned ?? 0)), + losses: BigInt(String(r.losses ?? 0)), }; } -type ProposalStatusRaw = 'Pending' | 'Approved' | 'Executed' | 'Rejected'; - -export async function getRoleConfig(role: Role): Promise { +export async function getTranchePool(token: string): Promise { const sim = await simulateTx( - ACCESS_CONTROL_CONTRACT_ID, - 'get_role_config', - [roleToScVal(role)], - SIMULATION_SOURCE, + TRANCHE_CONTRACT_ID, + 'get_pool', + [new Address(token).toScVal()], + DUMMY_CALLER, ); const result = (sim as StellarRpc.Api.SimulateTransactionSuccessResponse).result; - const raw = scValToNative(result!.retval) as Record | null; - if (!raw) return null; + const raw = scValToNative(result!.retval) as Record; return { - signers: (raw.signers as StellarAddress[]) ?? [], - threshold: Number(raw.threshold ?? 0), + senior: accountingFromRaw(raw.senior as Record), + junior: accountingFromRaw(raw.junior as Record), + config: raw.config as TrancheConfig, }; } -/** Fetches every role's config in one round-trip, for the admin roles page. */ -export async function listAllRoleConfigs(): Promise> { - const entries = await Promise.all( - ALL_ROLES.map(async (role) => [role, await getRoleConfig(role)] as const), - ); - return Object.fromEntries(entries) as Record; -} - -export async function isRoleSigner(role: Role, address: string): Promise { - const sim = await simulateTx( - ACCESS_CONTROL_CONTRACT_ID, - 'is_signer', - [roleToScVal(role), new Address(address).toScVal()], - SIMULATION_SOURCE, - ); - const result = (sim as StellarRpc.Api.SimulateTransactionSuccessResponse).result; - return Boolean(scValToNative(result!.retval)); -} - -export async function getProposal(proposalId: number): Promise { - const sim = await simulateTx( - ACCESS_CONTROL_CONTRACT_ID, - 'get_proposal', - [nativeToScVal(proposalId, { type: 'u64' })], - SIMULATION_SOURCE, - ); - const result = (sim as StellarRpc.Api.SimulateTransactionSuccessResponse).result; - const raw = scValToNative(result!.retval) as Record | null; - return raw ? proposalFromScVal(raw) : null; -} - -async function getNextProposalId(): Promise { +export async function getTrancheInvestorPosition( + investor: string, + token: string, + trancheClass: TrancheClass, +): Promise<{ deposited: bigint; shares: bigint; earned: bigint; losses: bigint }> { const sim = await simulateTx( - ACCESS_CONTROL_CONTRACT_ID, - 'get_next_proposal_id', - [], - SIMULATION_SOURCE, + TRANCHE_CONTRACT_ID, + 'get_position', + [ + new Address(investor).toScVal(), + new Address(token).toScVal(), + trancheClassToScVal(trancheClass), + ], + DUMMY_CALLER, ); const result = (sim as StellarRpc.Api.SimulateTransactionSuccessResponse).result; - return Number(scValToNative(result!.retval)); -} - -/** - * Fetches every proposal in `0..getNextProposalId()` — no pagination, since - * this governance system is meant for tens of proposals, not thousands. - */ -export async function listProposals(): Promise> { - if (!ACCESS_CONTROL_CONTRACT_ID) return []; - const nextId = await getNextProposalId(); - const results: Array<{ id: number; proposal: Proposal }> = []; - for (let id = 0; id < nextId; id++) { - const proposal = await getProposal(id); - if (proposal) results.push({ id, proposal }); - } - return results; + const raw = scValToNative(result!.retval) as Record; + return { + deposited: BigInt(String(raw.deposited ?? 0)), + shares: BigInt(String(raw.shares ?? 0)), + earned: BigInt(String(raw.earned ?? 0)), + losses: BigInt(String(raw.losses ?? 0)), + }; } -export async function buildProposeActionTx(params: { - role: Role; - proposer: string; - target: string; - action: ActionPayload; -}): Promise { - const account = await getRpcAccount(params.proposer); - const contract = new Contract(ACCESS_CONTROL_CONTRACT_ID); - +export async function buildTrancheDepositTx( + investor: string, + token: string, + trancheClass: TrancheClass, + amount: bigint, +): Promise { + const account = await getRpcAccount(investor); + const contract = new Contract(TRANCHE_CONTRACT_ID); const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) .addOperation( contract.call( - 'propose_action', - roleToScVal(params.role), - new Address(params.proposer).toScVal(), - new Address(params.target).toScVal(), - actionPayloadToScVal(params.action), + 'deposit_tranche', + new Address(investor).toScVal(), + new Address(token).toScVal(), + trancheClassToScVal(trancheClass), + nativeToScVal(amount, { type: 'i128' }), ), ) .setTimeout(30) .build(); - const sim = await simulateRpcTransaction(tx); - if (StellarRpc.Api.isSimulationError(sim)) { - throw new Error(`Simulation failed: ${sim.error}`); - } + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); return StellarRpc.assembleTransaction(tx, sim).build().toXDR(); } -function buildSignerOnlyActionTx( - method: 'approve_action' | 'reject_action' | 'revoke_approval', -): (params: { signer: string; proposalId: number }) => Promise { - return async ({ signer, proposalId }) => { - const account = await getRpcAccount(signer); - const contract = new Contract(ACCESS_CONTROL_CONTRACT_ID); - - const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) - .addOperation( - contract.call( - method, - new Address(signer).toScVal(), - nativeToScVal(proposalId, { type: 'u64' }), - ), - ) - .setTimeout(30) - .build(); - - const sim = await simulateRpcTransaction(tx); - if (StellarRpc.Api.isSimulationError(sim)) { - throw new Error(`Simulation failed: ${sim.error}`); - } - return StellarRpc.assembleTransaction(tx, sim).build().toXDR(); - }; -} - -export const buildApproveActionTx = buildSignerOnlyActionTx('approve_action'); -export const buildRejectActionTx = buildSignerOnlyActionTx('reject_action'); -export const buildRevokeApprovalTx = buildSignerOnlyActionTx('revoke_approval'); - -export async function buildExecuteActionTx(params: { - caller: string; - proposalId: number; -}): Promise { - const account = await getRpcAccount(params.caller); - const contract = new Contract(ACCESS_CONTROL_CONTRACT_ID); - +export async function buildSetTrancheConfigTx( + admin: string, + token: string, + seniorTargetYieldBps: number, + seniorAdvanceRateBps: number, + juniorFirstLossBps: number, +): Promise { + const account = await getRpcAccount(admin); + const contract = new Contract(TRANCHE_CONTRACT_ID); const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) .addOperation( contract.call( - 'execute_action', - new Address(params.caller).toScVal(), - nativeToScVal(params.proposalId, { type: 'u64' }), + 'set_tranche_config', + new Address(admin).toScVal(), + new Address(token).toScVal(), + nativeToScVal(seniorTargetYieldBps, { type: 'u32' }), + nativeToScVal(seniorAdvanceRateBps, { type: 'u32' }), + nativeToScVal(juniorFirstLossBps, { type: 'u32' }), ), ) .setTimeout(30) .build(); - const sim = await simulateRpcTransaction(tx); - if (StellarRpc.Api.isSimulationError(sim)) { - throw new Error(`Simulation failed: ${sim.error}`); - } + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); return StellarRpc.assembleTransaction(tx, sim).build().toXDR(); } diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 48ddf4d7..bba59bcd 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -41,6 +41,10 @@ export const ORACLE_REGISTRY_CONTRACT_ID = // #867: on-chain compliance / sanctions screening registry export const COMPLIANCE_CONTRACT_ID = process.env.NEXT_PUBLIC_COMPLIANCE_CONTRACT_ID ?? ''; +// #862: invoice tranching (senior/junior) with waterfall repayment — optional, +// unset until deployed. +export const TRANCHE_CONTRACT_ID = process.env.NEXT_PUBLIC_TRANCHE_CONTRACT_ID ?? ''; + // #799: on-chain referral program — optional, unset until deployed. export const REFERRAL_CONTRACT_ID = process.env.NEXT_PUBLIC_REFERRAL_CONTRACT_ID ?? ''; diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 48d56fe9..ac0d2a56 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -121,7 +121,13 @@ export interface RateSnapshot { rateBps: number; } -export type ProposalStatus = 'Active' | 'Passed' | 'Rejected' | 'Executed' | 'Cancelled' | 'Expired'; +export type ProposalStatus = + | 'Active' + | 'Passed' + | 'Rejected' + | 'Executed' + | 'Cancelled' + | 'Expired'; export interface GovernanceProposal { id: number; diff --git a/indexer/src/api.ts b/indexer/src/api.ts index 1b037dd3..ea75e46c 100644 --- a/indexer/src/api.ts +++ b/indexer/src/api.ts @@ -2,9 +2,9 @@ * Express REST API for querying indexed Soroban events. */ -import express from "express"; -import Database from "better-sqlite3"; -import { getEvents } from "./db"; +import express from 'express'; +import Database from 'better-sqlite3'; +import { getEvents, getTrancheApy } from './db'; export function startApiServer( db: Database.Database, @@ -632,6 +632,55 @@ export function startApiServer( } }); + // #862: trailing realized APY per tranche per token, from the derived + // tranche_apy table (recomputed after each ingest batch). Serializes the + // i128 principal/return fields as strings to preserve precision. + app.get('/tranches/apy', (req, res) => { + try { + const token = typeof req.query.token === 'string' ? req.query.token : undefined; + const rows = getTrancheApy(db, token).map((r) => ({ + token: r.token, + trancheClass: r.trancheClass, + realizedPrincipal: r.realizedPrincipal.toString(), + realizedReturn: r.realizedReturn.toString(), + closedPositions: r.closedPositions, + realizedApyBps: r.realizedApyBps, + realizedApyPct: r.realizedApyBps / 100, + updatedAt: r.updatedAt, + })); + res.json({ tranches: rows, count: rows.length }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + // #862: trailing realized APY for a single token, split into senior/junior. + app.get('/tranches/:token/apy', (req, res) => { + try { + const { token } = req.params; + if (!token) { + return res.status(400).json({ error: 'token path param is required' }); + } + const rows = getTrancheApy(db, token); + const find = (cls: 'Senior' | 'Junior') => { + const r = rows.find((x) => x.trancheClass === cls); + return r + ? { + realizedPrincipal: r.realizedPrincipal.toString(), + realizedReturn: r.realizedReturn.toString(), + closedPositions: r.closedPositions, + realizedApyBps: r.realizedApyBps, + realizedApyPct: r.realizedApyBps / 100, + updatedAt: r.updatedAt, + } + : { realizedPrincipal: '0', realizedReturn: '0', closedPositions: 0, realizedApyBps: 0, realizedApyPct: 0, updatedAt: null }; + }; + return res.json({ token, senior: find('Senior'), junior: find('Junior') }); + } catch (err: any) { + return res.status(500).json({ error: err.message }); + } + }); + // Get latest ledger app.get("/ledger/latest", (_req, res) => { try { diff --git a/indexer/src/db.ts b/indexer/src/db.ts index d2dce6e8..50a1b15e 100644 --- a/indexer/src/db.ts +++ b/indexer/src/db.ts @@ -32,6 +32,23 @@ const MIGRATIONS = [ ON events(contract_type); `; }, + // #862: derived table for historical realized APY per tranche per token. + // Recomputed from indexed tranche fund/repay/default events (see + // recomputeTrancheApy). One row per (token, tranche_class). + () => { + return ` + CREATE TABLE IF NOT EXISTS tranche_apy ( + token TEXT NOT NULL, + tranche_class TEXT NOT NULL, + realized_principal TEXT NOT NULL DEFAULT '0', + realized_return TEXT NOT NULL DEFAULT '0', + closed_positions INTEGER NOT NULL DEFAULT 0, + realized_apy_bps INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL, + PRIMARY KEY (token, tranche_class) + ); + `; + }, ]; function runMigrationsFrom(db: Database.Database, fromVersion: number): void { @@ -269,3 +286,187 @@ export function getLatestLedger(db: Database.Database): string | null { return row ? row.ledger_sequence.toString() : null; } + +// #862: historical realized APY per tranche per token. +// +// Each closed invoice contributes to the trailing realized yield of both +// tranches. From the indexed tranche events we reconstruct, per invoice: +// fund -> (invoice_id, token, senior_deployed, junior_deployed) +// repay -> (invoice_id, token, senior_payout, junior_payout) +// default-> (invoice_id, token, junior_loss, senior_loss) +// Realized return for a tranche on an invoice is payout - deployed (a repaid +// invoice), or -loss (a defaulted invoice). Aggregated across every closed +// invoice for a token, realized_return / realized_principal is the trailing +// realized yield; we annualize crudely to basis points assuming the sampled +// invoices represent roughly one turnover. Callers wanting a time-weighted +// figure can derive it from the raw fields, which are exposed as strings to +// preserve i128 precision. +export interface TrancheApyRow { + token: string; + trancheClass: 'Senior' | 'Junior'; + realizedPrincipal: bigint; + realizedReturn: bigint; + closedPositions: number; + realizedApyBps: number; + updatedAt: string; +} + +const TRANCHE_CLASS_LABEL: Record = { + 0: 'Senior', + 1: 'Junior', +}; + +function trancheClassLabel(raw: unknown): 'Senior' | 'Junior' | null { + if (raw === 'Senior' || raw === 'Junior') return raw; + if (typeof raw === 'number' && raw in TRANCHE_CLASS_LABEL) return TRANCHE_CLASS_LABEL[raw]; + // soroban enum unit variants serialize as { Senior: [] } / ["Senior"] + if (Array.isArray(raw) && (raw[0] === 'Senior' || raw[0] === 'Junior')) return raw[0]; + if (raw && typeof raw === 'object') { + if ('Senior' in (raw as object)) return 'Senior'; + if ('Junior' in (raw as object)) return 'Junior'; + } + return null; +} + +function toBigInt(v: unknown): bigint { + try { + return BigInt(String(v ?? 0)); + } catch { + return 0n; + } +} + +/** + * Recompute the tranche_apy table from scratch off the indexed tranche event + * stream. Idempotent: safe to call after every ingest batch. + */ +export function recomputeTrancheApy(db: Database.Database, updatedAt: string): void { + const rows = getEvents(db, { contractType: 'tranche', limit: 100_000, offset: 0 }); + + // token -> tranche -> accumulator + type Acc = { principal: bigint; ret: bigint; closed: number }; + const agg = new Map(); + const bucket = (token: string) => { + let b = agg.get(token); + if (!b) { + b = { + Senior: { principal: 0n, ret: 0n, closed: 0 }, + Junior: { principal: 0n, ret: 0n, closed: 0 }, + }; + agg.set(token, b); + } + return b; + }; + + // Deployed principal per invoice, from `fund` events, so repay/default can + // net against it. + const deployed = new Map(); + + // Process in chronological order so fund precedes its repay/default. + const ordered = [...rows].sort((a, b) => a.ledgerSequence - b.ledgerSequence); + + for (const evt of ordered) { + const v = evt.value; + if (!Array.isArray(v) || v.length < 4) continue; + const invoiceId = String(v[0]); + const token = typeof v[1] === 'string' ? v[1] : String(v[1]); + + switch (evt.eventType) { + case 'fund': { + deployed.set(invoiceId, { + token, + senior: toBigInt(v[2]), + junior: toBigInt(v[3]), + }); + break; + } + case 'repay': { + const dep = deployed.get(invoiceId); + if (!dep) break; + const b = bucket(token); + b.Senior.principal += dep.senior; + b.Senior.ret += toBigInt(v[2]) - dep.senior; + b.Senior.closed += 1; + b.Junior.principal += dep.junior; + b.Junior.ret += toBigInt(v[3]) - dep.junior; + b.Junior.closed += 1; + deployed.delete(invoiceId); + break; + } + case 'default': { + const dep = deployed.get(invoiceId); + const b = bucket(token); + // default value tuple is (invoice_id, token, junior_loss, senior_loss) + const juniorLoss = toBigInt(v[2]); + const seniorLoss = toBigInt(v[3]); + b.Junior.principal += dep ? dep.junior : juniorLoss; + b.Junior.ret += -juniorLoss; + b.Junior.closed += 1; + b.Senior.principal += dep ? dep.senior : seniorLoss; + b.Senior.ret += -seniorLoss; + b.Senior.closed += 1; + deployed.delete(invoiceId); + break; + } + default: + break; + } + } + + const upsert = db.prepare(` + INSERT INTO tranche_apy + (token, tranche_class, realized_principal, realized_return, closed_positions, realized_apy_bps, updated_at) + VALUES (@token, @trancheClass, @principal, @ret, @closed, @apyBps, @updatedAt) + ON CONFLICT(token, tranche_class) DO UPDATE SET + realized_principal = @principal, + realized_return = @ret, + closed_positions = @closed, + realized_apy_bps = @apyBps, + updated_at = @updatedAt + `); + + const writeAll = db.transaction(() => { + for (const [token, b] of agg) { + for (const cls of ['Senior', 'Junior'] as const) { + const acc = b[cls]; + const apyBps = + acc.principal > 0n + ? Number((acc.ret * 10_000n) / acc.principal) + : 0; + upsert.run({ + token, + trancheClass: cls, + principal: acc.principal.toString(), + ret: acc.ret.toString(), + closed: acc.closed, + apyBps, + updatedAt, + }); + } + } + }); + writeAll(); +} + +export function getTrancheApy( + db: Database.Database, + token?: string, +): TrancheApyRow[] { + let query = 'SELECT * FROM tranche_apy'; + const params: any[] = []; + if (token) { + query += ' WHERE token = ?'; + params.push(token); + } + query += ' ORDER BY token, tranche_class'; + const rows = db.prepare(query).all(...params) as any[]; + return rows.map((r) => ({ + token: r.token, + trancheClass: r.tranche_class, + realizedPrincipal: toBigInt(r.realized_principal), + realizedReturn: toBigInt(r.realized_return), + closedPositions: r.closed_positions, + realizedApyBps: r.realized_apy_bps, + updatedAt: r.updated_at, + })); +} diff --git a/indexer/src/index.ts b/indexer/src/index.ts index 8ff7f8b0..7d6ad95e 100644 --- a/indexer/src/index.ts +++ b/indexer/src/index.ts @@ -6,10 +6,10 @@ * parses them, and stores them in a SQLite database for fast querying. */ -import { Horizon } from "stellar-sdk"; -import { parseEvents } from "./parser"; -import { initDb, storeEvents, getEvents, getLatestLedger } from "./db"; -import { startApiServer } from "./api"; +import { Horizon } from 'stellar-sdk'; +import { parseEvents } from './parser'; +import { initDb, storeEvents, getEvents, getLatestLedger, recomputeTrancheApy } from './db'; +import { startApiServer } from './api'; const HORIZON_URL = process.env.HORIZON_URL || "https://horizon-testnet.stellar.org"; @@ -235,14 +235,24 @@ async function pollLoop(db: any, state: { lastProcessedLedger: string }) { console.log(`[Astera Indexer] Stored ${events.length} events`); const lastEvent = events[events.length - 1]; cursor = lastEvent.ledgerSequence?.toString() || cursor; - state.lastProcessedLedger = cursor; + + // #862: refresh the derived per-tranche realized-APY table whenever a + // tranche event lands in the batch, so the /tranches/apy endpoint stays + // current for the frontend invest page. + if (events.some((e) => e.contractType === 'tranche')) { + try { + recomputeTrancheApy(db, new Date().toISOString()); + } catch (apyErr) { + console.error('[Astera Indexer] Failed to recompute tranche APY:', apyErr); + } + } } // Check if there are more pages if (response.records && response.records.length > 0) { const lastRecord = response.records[response.records.length - 1]; cursor = lastRecord.paging_token || cursor; - state.lastProcessedLedger = cursor; + // state.lastProcessedLedger = cursor; } consecutiveFailures = 0; diff --git a/indexer/src/parser.ts b/indexer/src/parser.ts index 1f35d70b..dae24c49 100644 --- a/indexer/src/parser.ts +++ b/indexer/src/parser.ts @@ -13,6 +13,7 @@ export type ContractType = | 'credit_score' | 'oracle_registry' | 'compliance' + | 'tranche' | 'unknown'; export interface IndexedEvent { @@ -36,6 +37,8 @@ const POOL_CONTRACT_ID = (process.env.POOL_CONTRACT_ID || '').trim(); const ORACLE_REGISTRY_CONTRACT_ID = (process.env.ORACLE_REGISTRY_CONTRACT_ID || '').trim(); // #867: on-chain compliance / sanctions screening registry const COMPLIANCE_CONTRACT_ID = (process.env.COMPLIANCE_CONTRACT_ID || '').trim(); +// #862: invoice tranching (senior/junior) with waterfall repayment and loss allocation +const TRANCHE_CONTRACT_ID = (process.env.TRANCHE_CONTRACT_ID || '').trim(); // #861: oracle_registry contract emits these event subtypes under the // "ORACLE" topic (see `EVT` in contracts/oracle_registry/src/lib.rs). @@ -69,6 +72,16 @@ const COMPLIANCE_EVENT_TYPES = new Set([ 'unpaused', ]); +// #862: tranche contract emits these event subtypes under the "TRANCHE" topic +const TRANCHE_EVENT_TYPES = new Set([ + 'deposit', + 'withdraw', + 'fund', + 'repay', + 'default', + 'config', +]); + // #700: credit_score contract emits these event subtypes under the "CREDIT" topic const CREDIT_SCORE_EVENT_TYPES = new Set([ 'payment', @@ -101,9 +114,13 @@ function classifyContract(contractId: string, contractType: string, eventType: s if (COMPLIANCE_CONTRACT_ID && contractId === COMPLIANCE_CONTRACT_ID) { return 'compliance'; } + if (TRANCHE_CONTRACT_ID && contractId === TRANCHE_CONTRACT_ID) { + return 'tranche'; + } // Fallback: infer from topic. credit_score events publish under "CREDIT", // oracle_registry events publish under "ORACLE" (#861), - // compliance events publish under "COMPLY" (#867). + // compliance events publish under "COMPLY" (#867), + // tranche events publish under "TRANCHE" (#862). if (contractType === 'CREDIT' || CREDIT_SCORE_EVENT_TYPES.has(eventType)) { return 'credit_score'; } @@ -113,6 +130,9 @@ function classifyContract(contractId: string, contractType: string, eventType: s if (contractType === 'COMPLY' || COMPLIANCE_EVENT_TYPES.has(eventType)) { return 'compliance'; } + if (contractType === 'TRANCHE' || TRANCHE_EVENT_TYPES.has(eventType)) { + return 'tranche'; + } if (contractType === 'invoice') return 'invoice'; if (contractType === 'pool') return 'pool'; return 'unknown'; @@ -257,5 +277,19 @@ function extractActor(contractType: string, eventType: string, value: any): stri } } + // Tranche events: first field is usually the actor + if (contractType === 'TRANCHE') { + switch (eventType) { + case 'deposit': + case 'withdraw': + case 'fund': + case 'repay': + case 'default': + return value[0]; // investor or caller + case 'config': + return value[0]; // admin + } + } + return null; } diff --git a/packages/sdk/src/astera-client.ts b/packages/sdk/src/astera-client.ts index e11b4bc1..d2e3e023 100644 --- a/packages/sdk/src/astera-client.ts +++ b/packages/sdk/src/astera-client.ts @@ -3,7 +3,16 @@ import { PoolClient } from './clients/pool'; import { CreditScoreClient } from './clients/credit_score'; import { OracleRegistryClient } from './clients/oracle_registry'; import { ComplianceClient } from './clients/compliance'; +import { TrancheClient, type TrancheInvestorPosition } from './clients/tranche'; import { AccessControlClient } from './clients/access_control'; +import type { + TranchePool, + TrancheConfig, + TrancheAccounting, + InvoiceTrancheExposure, + WaterfallSimulation, + TrancheClass, +} from '../../../sdk/src/generated/tranche'; import type { AsteraConfig, Invoice, @@ -38,6 +47,7 @@ export class AsteraClient { private creditScoreClient: CreditScoreClient; private oracleRegistryClient: OracleRegistryClient; private complianceClient: ComplianceClient; + private trancheClient: TrancheClient; private accessControlClient: AccessControlClient; constructor(config: AsteraConfig) { @@ -66,6 +76,11 @@ export class AsteraClient { network: config.network, contractId: config.complianceContractId ?? '', }); + this.trancheClient = new TrancheClient({ + rpcUrl: config.rpcUrl, + network: config.network, + contractId: config.trancheContractId ?? '', + }); this.accessControlClient = new AccessControlClient({ rpcUrl: config.rpcUrl, network: config.network, @@ -348,6 +363,69 @@ export class AsteraClient { this.complianceClient.requestReview(params), }; + public readonly tranche = { + getPool: (token: string): Promise => + this.trancheClient.getPool(token), + + getConfig: (token: string): Promise => + this.trancheClient.getTrancheConfig(token), + + getTotals: (token: string, trancheClass: TrancheClass): Promise => + this.trancheClient.getTotals(token, trancheClass), + + getPosition: ( + investor: string, + token: string, + trancheClass: TrancheClass, + ): Promise => + this.trancheClient.getInvestorPosition(investor, token, trancheClass), + + getEffectiveApy: (token: string, trancheClass: TrancheClass): Promise => + this.trancheClient.getEffectiveApy(token, trancheClass), + + getInvoiceExposure: (invoiceId: number): Promise => + this.trancheClient.getInvoiceExposure(invoiceId), + + simulateWaterfall: ( + token: string, + invoiceId: number, + hypotheticalRepayment: bigint, + elapsedSecs: number, + ): Promise => + this.trancheClient.simulateWaterfall(token, invoiceId, hypotheticalRepayment, elapsedSecs), + + deposit: (params: { + signer: (txXdr: string) => Promise; + investor: string; + token: string; + trancheClass: TrancheClass; + amount: bigint; + onProgress?: (progress: TransactionProgress) => void; + }): Promise => + this.trancheClient.deposit(params), + + withdraw: (params: { + signer: (txXdr: string) => Promise; + investor: string; + token: string; + trancheClass: TrancheClass; + amount: bigint; + onProgress?: (progress: TransactionProgress) => void; + }): Promise => + this.trancheClient.withdraw(params), + + setConfig: (params: { + signer: (txXdr: string) => Promise; + admin: string; + token: string; + seniorTargetYieldBps: number; + seniorAdvanceRateBps: number; + juniorFirstLossBps: number; + onProgress?: (progress: TransactionProgress) => void; + }): Promise => + this.trancheClient.setTrancheConfig(params), + }; + /** #864: role-based multisig access control. */ public readonly accessControl = { getRoleConfig: (role: Role): Promise => diff --git a/packages/sdk/src/clients/tranche.ts b/packages/sdk/src/clients/tranche.ts new file mode 100644 index 00000000..473993f7 --- /dev/null +++ b/packages/sdk/src/clients/tranche.ts @@ -0,0 +1,209 @@ +import { rpc as StellarRpc } from '@stellar/stellar-sdk'; +import { BaseClient, nativeToScVal, scValToNative, Address, xdr } from './base'; +import type { ClientConfig, TransactionProgress } from '../types'; +import type { Signer } from '../types'; +import type { + TranchePool, + TrancheConfig, + TrancheAccounting, + InvoiceTrancheExposure, + WaterfallSimulation, +} from '../../../../sdk/src/generated/tranche'; +import { TrancheClass } from '../../../../sdk/src/generated/tranche'; + +export interface TrancheInvestorPosition { + deposited: bigint; + shares: bigint; + earned: bigint; + losses: bigint; +} + +function trancheClassToScVal(tc: TrancheClass): xdr.ScVal { + const name = tc === TrancheClass.Senior ? 'Senior' : 'Junior'; + return xdr.ScVal.scvVec([nativeToScVal(name, { type: 'symbol' })]); +} + +function accountingFromRaw(r: Record): TrancheAccounting { + return { + deposited: BigInt(String(r.deposited)), + available: BigInt(String(r.available)), + deployed: BigInt(String(r.deployed)), + earned: BigInt(String(r.earned)), + losses: BigInt(String(r.losses)), + }; +} + +function poolFromRaw(raw: Record): TranchePool { + return { + senior: accountingFromRaw(raw.senior as Record), + junior: accountingFromRaw(raw.junior as Record), + config: raw.config as TrancheConfig, + }; +} + +export class TrancheClient extends BaseClient { + constructor(config: ClientConfig) { + super(config); + } + + async getPool(token: string): Promise { + const sim = await this.simulate('get_pool', [new Address(token).toScVal()]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + return poolFromRaw(scValToNative(sim.result!.retval) as Record); + } + + async getTrancheConfig(token: string): Promise { + const sim = await this.simulate('get_config', [new Address(token).toScVal()]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + const raw = scValToNative(sim.result!.retval) as Record; + return { + senior_target_yield_bps: Number(raw.senior_target_yield_bps), + senior_advance_rate_bps: Number(raw.senior_advance_rate_bps), + junior_first_loss_bps: Number(raw.junior_first_loss_bps), + }; + } + + async getInvestorPosition( + investor: string, + token: string, + trancheClass: TrancheClass, + ): Promise { + const sim = await this.simulate('get_position', [ + new Address(investor).toScVal(), + new Address(token).toScVal(), + trancheClassToScVal(trancheClass), + ]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + const raw = scValToNative(sim.result!.retval) as Record; + return { + deposited: BigInt(String(raw.deposited ?? 0)), + shares: BigInt(String(raw.shares ?? 0)), + earned: BigInt(String(raw.earned ?? 0)), + losses: BigInt(String(raw.losses ?? 0)), + }; + } + + async getTotals(token: string, trancheClass: TrancheClass): Promise { + const sim = await this.simulate('get_totals', [ + new Address(token).toScVal(), + trancheClassToScVal(trancheClass), + ]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + return accountingFromRaw(scValToNative(sim.result!.retval) as Record); + } + + async getEffectiveApy(token: string, trancheClass: TrancheClass): Promise { + const sim = await this.simulate('get_effective_apy', [ + new Address(token).toScVal(), + trancheClassToScVal(trancheClass), + ]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + return Number(scValToNative(sim.result!.retval)); + } + + async getInvoiceExposure(invoiceId: number): Promise { + const sim = await this.simulate('get_invoice_exposure', [ + nativeToScVal(invoiceId, { type: 'u64' }), + ]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + const raw = scValToNative(sim.result!.retval); + if (!raw) return null; + const r = raw as Record; + return { + invoice_id: Number(r.invoice_id), + senior_principal: BigInt(String(r.senior_deployed)), + junior_principal: BigInt(String(r.junior_deployed)), + senior_repaid: BigInt(String(r.senior_repaid ?? 0)), + junior_repaid: BigInt(String(r.junior_repaid ?? 0)), + senior_loss: BigInt(String(r.senior_loss ?? 0)), + junior_loss: BigInt(String(r.junior_loss ?? 0)), + }; + } + + async simulateWaterfall( + token: string, + invoiceId: number, + hypotheticalRepayment: bigint, + elapsedSecs: number, + ): Promise { + const sim = await this.simulate('simulate_waterfall', [ + new Address(token).toScVal(), + nativeToScVal(invoiceId, { type: 'u64' }), + nativeToScVal(hypotheticalRepayment, { type: 'i128' }), + nativeToScVal(elapsedSecs, { type: 'u64' }), + ]); + if (StellarRpc.Api.isSimulationError(sim)) throw new Error(`Simulation failed: ${sim.error}`); + const [seniorAmount, juniorAmount] = scValToNative(sim.result!.retval) as [unknown, unknown]; + return { + senior_amount: BigInt(String(seniorAmount)), + junior_amount: BigInt(String(juniorAmount)), + senior_cap: 0n, + elapsed_yield: 0n, + }; + } + + async deposit(params: { + signer: Signer; + investor: string; + token: string; + trancheClass: TrancheClass; + amount: bigint; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.investor, + 'deposit_tranche', + [ + new Address(params.investor).toScVal(), + new Address(params.token).toScVal(), + trancheClassToScVal(params.trancheClass), + nativeToScVal(params.amount, { type: 'i128' }), + ], + params.onProgress, + ); + } + + async withdraw(params: { + signer: Signer; + investor: string; + token: string; + trancheClass: TrancheClass; + amount: bigint; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.investor, + 'withdraw_tranche', + [ + new Address(params.investor).toScVal(), + new Address(params.token).toScVal(), + trancheClassToScVal(params.trancheClass), + nativeToScVal(params.amount, { type: 'i128' }), + ], + params.onProgress, + ); + } + + async setTrancheConfig(params: { + signer: Signer; + admin: string; + token: string; + seniorTargetYieldBps: number; + seniorAdvanceRateBps: number; + juniorFirstLossBps: number; + onProgress?: (progress: TransactionProgress) => void; + }): Promise { + return this.buildAndSendTx( + params.admin, + 'set_tranche_config', + [ + new Address(params.admin).toScVal(), + new Address(params.token).toScVal(), + nativeToScVal(params.seniorTargetYieldBps, { type: 'u32' }), + nativeToScVal(params.seniorAdvanceRateBps, { type: 'u32' }), + nativeToScVal(params.juniorFirstLossBps, { type: 'u32' }), + ], + params.onProgress, + ); + } +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 20719044..f2168990 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -141,6 +141,7 @@ export interface AsteraConfig { creditScoreContractId?: string; oracleRegistryContractId?: string; complianceContractId?: string; + trancheContractId?: string; /** #864: role-based multisig access-control contract, if deployed. */ accessControlContractId?: string; } diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 6b6d2d45..d368a8cc 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -30,4 +30,8 @@ export type { ActionPayload, Proposal, } from '../../packages/sdk/src/types'; -export { ALL_ROLES, ROLE_LABELS } from '../../packages/sdk/src/types'; + +export * from './generated/tranche'; + +export { TrancheClient } from '../../packages/sdk/src/clients/tranche'; +export type { TrancheInvestorPosition } from '../../packages/sdk/src/clients/tranche'; diff --git a/sdk/src/generated/tranche.ts b/sdk/src/generated/tranche.ts new file mode 100644 index 00000000..544738e0 --- /dev/null +++ b/sdk/src/generated/tranche.ts @@ -0,0 +1,58 @@ +export const Errors = { + 0: { message: 'AlreadyInitialized' }, + 1: { message: 'Unauthorized' }, + 2: { message: 'PoolNotFound' }, + 3: { message: 'InvalidAmount' }, + 4: { message: 'InsufficientBalance' }, + 5: { message: 'AdvanceRateExceeded' }, + 6: { message: 'InvalidTranche' }, + 7: { message: 'ArithmeticOverflow' }, + 8: { message: 'WaterfallError' }, + 9: { message: 'LossAllocationError' }, + 10: { message: 'ReentrancyDetected' }, +} as const; + +export type TrancheErrorCode = keyof typeof Errors; +export type TrancheErrorMessage = (typeof Errors)[TrancheErrorCode]['message']; + +export enum TrancheClass { + Senior = 0, + Junior = 1, +} + +export interface TrancheConfig { + senior_target_yield_bps: number; + senior_advance_rate_bps: number; + junior_first_loss_bps: number; +} + +export interface TrancheAccounting { + deposited: bigint; + available: bigint; + deployed: bigint; + earned: bigint; + losses: bigint; +} + +export interface TranchePool { + senior: TrancheAccounting; + junior: TrancheAccounting; + config: TrancheConfig; +} + +export interface InvoiceTrancheExposure { + invoice_id: number; + senior_principal: bigint; + junior_principal: bigint; + senior_repaid: bigint; + junior_repaid: bigint; + senior_loss: bigint; + junior_loss: bigint; +} + +export interface WaterfallSimulation { + senior_amount: bigint; + junior_amount: bigint; + senior_cap: bigint; + elapsed_yield: bigint; +}