From f3bcd4b0f485d527efd3488381e40cc0c68fdc62 Mon Sep 17 00:00:00 2001 From: DIFoundation Date: Tue, 28 Jul 2026 03:17:42 +0100 Subject: [PATCH 1/2] solve contract, sdk nd frontend --- Cargo.lock | 15 +- Cargo.toml | 1 + contracts/tranche/Cargo.toml | 17 + contracts/tranche/src/deposit.rs | 78 +++ contracts/tranche/src/errors.rs | 18 + contracts/tranche/src/events.rs | 10 + contracts/tranche/src/funding.rs | 75 +++ contracts/tranche/src/lib.rs | 358 ++++++++++ contracts/tranche/src/math.rs | 43 ++ contracts/tranche/src/repayment.rs | 123 ++++ contracts/tranche/src/state.rs | 65 ++ contracts/tranche/src/withdraw.rs | 80 +++ contracts/tranche/tests/advance_rate_tests.rs | 135 ++++ contracts/tranche/tests/math_tests.rs | 224 +++++++ contracts/tranche/tests/scenario_tests.rs | 632 ++++++++++++++++++ frontend/app/admin/tranches/page.tsx | 341 ++++++++++ .../app/admin/waterfall-simulation/page.tsx | 404 +++++++++++ frontend/app/invest/tranches/page.tsx | 302 +++++++++ frontend/app/portfolio/page.tsx | 82 ++- frontend/package-lock.json | 1 - indexer/src/parser.ts | 36 +- sdk/src/client.ts | 94 +++ sdk/src/generated/tranche.ts | 58 ++ 23 files changed, 3179 insertions(+), 13 deletions(-) create mode 100644 contracts/tranche/Cargo.toml create mode 100644 contracts/tranche/src/deposit.rs create mode 100644 contracts/tranche/src/errors.rs create mode 100644 contracts/tranche/src/events.rs create mode 100644 contracts/tranche/src/funding.rs create mode 100644 contracts/tranche/src/lib.rs create mode 100644 contracts/tranche/src/math.rs create mode 100644 contracts/tranche/src/repayment.rs create mode 100644 contracts/tranche/src/state.rs create mode 100644 contracts/tranche/src/withdraw.rs create mode 100644 contracts/tranche/tests/advance_rate_tests.rs create mode 100644 contracts/tranche/tests/math_tests.rs create mode 100644 contracts/tranche/tests/scenario_tests.rs create mode 100644 frontend/app/admin/tranches/page.tsx create mode 100644 frontend/app/admin/waterfall-simulation/page.tsx create mode 100644 frontend/app/invest/tranches/page.tsx create mode 100644 sdk/src/generated/tranche.ts diff --git a/Cargo.lock b/Cargo.lock index 8a07a419..a6c82b7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -860,14 +860,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" -[[package]] -name = "insurance" -version = "0.1.0" -dependencies = [ - "proptest", - "soroban-sdk", -] - [[package]] name = "integration-tests" version = "0.1.0" @@ -1820,6 +1812,13 @@ dependencies = [ "time-core", ] +[[package]] +name = "tranche" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "typenum" version = "1.20.0" diff --git a/Cargo.toml b/Cargo.toml index 1392b05d..c135f88b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "contracts/referral", "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..8a8dfc72 --- /dev/null +++ b/contracts/tranche/Cargo.toml @@ -0,0 +1,17 @@ +[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"] } + +[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/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/tranches/page.tsx b/frontend/app/admin/tranches/page.tsx new file mode 100644 index 00000000..20846dd1 --- /dev/null +++ b/frontend/app/admin/tranches/page.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { useState } from 'react'; +import { TrancheClass, TrancheConfig } from '@/../sdk/src/generated/tranche'; + +interface TokenTrancheConfig { + token: string; + enabled: boolean; + config: TrancheConfig; + seniorShareToken: string; + juniorShareToken: string; +} + +export default function TrancheConfigPage() { + const [selectedToken, setSelectedToken] = useState('USDC'); + 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 = () => { + if (editingConfig) { + setConfigs( + configs.map((c) => + c.token === selectedToken ? { ...c, config: { ...editingConfig } } : c, + ), + ); + setEditingConfig(null); + setShowSaveModal(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/tranches/page.tsx b/frontend/app/invest/tranches/page.tsx new file mode 100644 index 00000000..41d6e6ac --- /dev/null +++ b/frontend/app/invest/tranches/page.tsx @@ -0,0 +1,302 @@ +'use client'; + +import { useState } from 'react'; +import { TrancheClass, TranchePool, TrancheConfig } from '@/../sdk/src/generated/tranche'; + +interface TrancheCardProps { + token: string; + trancheClass: TrancheClass; + pool: TranchePool; + targetApy: number; + trailingApy: number; + onDeposit: (amount: number) => 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; // Assuming 7 decimals + 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'} + +
+ + {/* APY Comparison */} +
+
+

Target APY

+

{targetApy.toFixed(1)}%

+
+
+

Trailing APY

+

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

+
+
+ + {/* Pool Statistics */} +
+
+ 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 */} +
+

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 +
+
+ )} +
+ + {/* Deposit Button */} + +
+ ); +} + +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 [selectedToken, setSelectedToken] = useState('USDC'); + const [depositAmount, setDepositAmount] = useState(0); + const [showDepositModal, setShowDepositModal] = useState(false); + const [selectedTranche, setSelectedTranche] = useState(null); + + // Mock data - in production, this would come from the indexer/contract + const mockPool: TranchePool = { + senior: { + deposited: 8000000000n, // $8,000 + available: 2400000000n, // $2,400 + deployed: 5600000000n, // $5,600 + earned: 450000000n, // $450 + losses: 0n, + }, + junior: { + deposited: 2000000000n, // $2,000 + available: 600000000n, // $600 + deployed: 1400000000n, // $1,400 + earned: 280000000n, // $280 + losses: 150000000n, // $150 + }, + config: { + senior_target_yield_bps: 1000, // 10% + senior_advance_rate_bps: 8000, // 80% + junior_first_loss_bps: 10000, // 100% + }, + }; + + const handleDeposit = (tranche: TrancheClass) => { + setSelectedTranche(tranche); + setShowDepositModal(true); + }; + + const confirmDeposit = () => { + // In production, this would call the SDK to execute the deposit + console.log( + `Depositing ${depositAmount} to ${selectedTranche === TrancheClass.Senior ? 'Senior' : 'Junior'}`, + ); + setShowDepositModal(false); + setDepositAmount(0); + }; + + return ( +
+
+ {/* Header */} +
+

Tranche Investments

+

+ Choose your risk profile with senior and junior tranches +

+
+ + {/* Token Selector */} +
+ + +
+ + {/* Risk Explainer */} +
+ +
+ + {/* Tranche Cards */} +
+ handleDeposit(TrancheClass.Senior)} + /> + handleDeposit(TrancheClass.Junior)} + /> +
+ + {/* Deposit Modal */} + {showDepositModal && ( +
+
+

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

+
+ + 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/portfolio/page.tsx b/frontend/app/portfolio/page.tsx index 60232e6e..6da20a88 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, @@ -135,7 +136,7 @@ function AllocationPie({ slices }: { slices: { label: string; pct: number; color } const cumulativeEnds = slices.reduce((acc, s) => { - const prev = acc.length > 0 ? acc[acc.length - 1] ?? 0 : 0; + const prev = acc.length > 0 ? (acc[acc.length - 1] ?? 0) : 0; return [...acc, prev + s.pct]; }, []); @@ -659,8 +660,9 @@ export default function PortfolioPage() {

Request withdrawal

- If pool liquidity can't cover it immediately, your request is queued - and settled automatically (FIFO, oldest-first) as liquidity arrives. + If pool liquidity can't cover it immediately, your request is + queued and settled automatically (FIFO, oldest-first) as liquidity + arrives.

)} + + {/* Tranche Positions Section */} +
+

Tranche Positions

+
+ {/* Senior Tranche Card */} +
+
+ + Senior Tranche + + + Lower Risk + +
+
+
+ Deposited + $2,500 +
+
+ Earned + $125 +
+
+ Losses + $0 +
+
+ APY + 9.8% +
+
+
+ + {/* Junior Tranche Card */} +
+
+ + Junior Tranche + + + Higher Risk + +
+
+
+ Deposited + $1,000 +
+
+ Earned + $142 +
+
+ Losses + $75 +
+
+ APY + 14.2% +
+
+
+
+ +
)}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c1ec1a07..3e0b2204 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16521,7 +16521,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/indexer/src/parser.ts b/indexer/src/parser.ts index f3f3c453..8abe5f1b 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'; @@ -221,5 +241,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/sdk/src/client.ts b/sdk/src/client.ts index 36e62938..4d2da7af 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -25,3 +25,97 @@ export type { ComplianceRecord, ScreeningHistoryEntry, } from '../../packages/sdk/src/types'; + +export * from './generated/tranche'; + +import { + TrancheClass, + TrancheConfig, + TranchePool, + InvoiceTrancheExposure, + WaterfallSimulation, +} from './generated/tranche'; + +export interface TrancheClientConfig { + contractId: string; + network: 'mainnet' | 'testnet'; +} + +export class TrancheClient { + constructor(private config: TrancheClientConfig) {} + + /** + * Deposit into a specific tranche + */ + async deposit( + investor: string, + token: string, + trancheClass: TrancheClass, + amount: bigint + ): Promise { + // Implementation would use Soroban SDK to invoke contract + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Withdraw from a specific tranche + */ + async withdraw( + investor: string, + token: string, + trancheClass: TrancheClass, + amount: bigint + ): Promise { + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Get tranche pool information for a token + */ + async getPool(token: string): Promise { + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Get investor's position in a specific tranche + */ + async getInvestorPosition( + investor: string, + token: string, + trancheClass: TrancheClass + ): Promise<{ deposited: bigint; available: bigint; earned: bigint; losses: bigint }> { + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Simulate waterfall repayment for a hypothetical scenario + */ + async simulateWaterfall( + invoiceId: number, + hypotheticalRepayment: bigint, + elapsedSecs: number + ): Promise { + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Get effective APY for a tranche (trailing realized yield) + */ + async getEffectiveApy(token: string, trancheClass: TrancheClass): Promise { + throw new Error('Not implemented - requires indexer data'); + } + + /** + * Get tranche configuration for a token + */ + async getTrancheConfig(token: string): Promise { + throw new Error('Not implemented - requires Soroban SDK integration'); + } + + /** + * Get invoice tranche exposure + */ + async getInvoiceExposure(invoiceId: number): Promise { + throw new Error('Not implemented - requires Soroban SDK integration'); + } +} 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; +} From d126a2da1b9657d0d37276096bb26329955fd2a8 Mon Sep 17 00:00:00 2001 From: DIFoundation Date: Tue, 28 Jul 2026 13:09:11 +0100 Subject: [PATCH 2/2] all requirment for the issue met --- Cargo.lock | 1 + contracts/tranche/Cargo.toml | 1 + contracts/tranche/tests/fuzz_tests.rs | 156 +++++++++++++++++++ frontend/app/admin/tranches/page.tsx | 44 +++++- frontend/app/invest/tranches/page.tsx | 209 ++++++++++++++++--------- frontend/app/portfolio/page.tsx | 213 ++++++++++++++++++-------- frontend/lib/contracts.ts | 119 ++++++++++++++ frontend/lib/stellar.ts | 4 + indexer/src/api.ts | 51 +++++- indexer/src/db.ts | 201 ++++++++++++++++++++++++ indexer/src/index.ts | 13 +- packages/sdk/src/astera-client.ts | 77 ++++++++++ packages/sdk/src/clients/tranche.ts | 209 +++++++++++++++++++++++++ packages/sdk/src/types.ts | 1 + sdk/src/client.ts | 93 +---------- 15 files changed, 1151 insertions(+), 241 deletions(-) create mode 100644 contracts/tranche/tests/fuzz_tests.rs create mode 100644 packages/sdk/src/clients/tranche.ts diff --git a/Cargo.lock b/Cargo.lock index a6c82b7d..6490f9cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,6 +1816,7 @@ dependencies = [ name = "tranche" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] diff --git a/contracts/tranche/Cargo.toml b/contracts/tranche/Cargo.toml index 8a8dfc72..343065b3 100644 --- a/contracts/tranche/Cargo.toml +++ b/contracts/tranche/Cargo.toml @@ -12,6 +12,7 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.4" [features] testutils = [] 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/frontend/app/admin/tranches/page.tsx b/frontend/app/admin/tranches/page.tsx index 20846dd1..44080722 100644 --- a/frontend/app/admin/tranches/page.tsx +++ b/frontend/app/admin/tranches/page.tsx @@ -1,7 +1,11 @@ 'use client'; import { useState } from 'react'; -import { TrancheClass, TrancheConfig } from '@/../sdk/src/generated/tranche'; +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; @@ -12,7 +16,9 @@ interface TokenTrancheConfig { } export default function TrancheConfigPage() { + const { wallet } = useStore(); const [selectedToken, setSelectedToken] = useState('USDC'); + const [saving, setSaving] = useState(false); const [configs, setConfigs] = useState([ { token: 'USDC', @@ -57,8 +63,30 @@ export default function TrancheConfigPage() { } }; - const handleSaveConfig = () => { - if (editingConfig) { + 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, @@ -66,6 +94,11 @@ export default function TrancheConfigPage() { ); 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); } }; @@ -226,9 +259,10 @@ export default function TrancheConfigPage() { <> ); @@ -167,52 +182,76 @@ function RiskExplainer() { } 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); - // Mock data - in production, this would come from the indexer/contract - const mockPool: TranchePool = { - senior: { - deposited: 8000000000n, // $8,000 - available: 2400000000n, // $2,400 - deployed: 5600000000n, // $5,600 - earned: 450000000n, // $450 - losses: 0n, - }, - junior: { - deposited: 2000000000n, // $2,000 - available: 600000000n, // $600 - deployed: 1400000000n, // $1,400 - earned: 280000000n, // $280 - losses: 150000000n, // $150 - }, - config: { - senior_target_yield_bps: 1000, // 10% - senior_advance_rate_bps: 8000, // 80% - junior_first_loss_bps: 10000, // 100% - }, - }; + 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 = () => { - // In production, this would call the SDK to execute the deposit - console.log( - `Depositing ${depositAmount} to ${selectedTranche === TrancheClass.Senior ? 'Senior' : 'Junior'}`, - ); - setShowDepositModal(false); - setDepositAmount(0); + 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 (
- {/* Header */}

Tranche Investments

@@ -220,7 +259,6 @@ export default function TranchesPage() {

- {/* Token Selector */}
- {/* Risk Explainer */}
- {/* Tranche Cards */} -
- handleDeposit(TrancheClass.Senior)} - /> - handleDeposit(TrancheClass.Junior)} - /> -
+ {loading && ( +
+ {[0, 1].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!loading && pool && ( +
+ handleDeposit(TrancheClass.Senior)} + /> + handleDeposit(TrancheClass.Junior)} + /> +
+ )} - {/* Deposit Modal */} {showDepositModal && (

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

+ {!wallet.connected && ( +

Connect your wallet to deposit.

+ )}