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 */}
+
+ Select Token
+ setSelectedToken(e.target.value)}
+ className="block w-full max-w-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
+ >
+ {configs.map((c) => (
+
+ {c.token}
+
+ ))}
+
+
+
+
+ {/* Configuration Panel */}
+
+
+
{selectedToken} Configuration
+
+ {currentConfig?.enabled ? 'Enabled' : 'Disabled'}
+
+
+
+ {currentConfig?.enabled ? (
+
+ {/* Senior Target Yield */}
+
+
+ Senior Target Yield (BPS)
+
+
+ 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 */}
+
+
+ Senior Advance Rate (BPS)
+
+
+ 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 */}
+
+
+ Junior First Loss (BPS)
+
+
+ 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 ? (
+ <>
+
+ {saving ? 'Saving…' : 'Save Changes'}
+
+ setEditingConfig(null)}
+ className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-lg font-medium hover:bg-gray-300"
+ >
+ Cancel
+
+ >
+ ) : (
+ <>
+ handleEditConfig(selectedToken)}
+ className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg font-medium hover:bg-blue-700"
+ >
+ Edit Configuration
+
+ handleDisableTranche(selectedToken)}
+ className="flex-1 bg-red-100 text-red-700 py-2 px-4 rounded-lg font-medium hover:bg-red-200"
+ >
+ Disable Tranche
+
+ >
+ )}
+
+
+ ) : (
+
+
Tranche is not enabled for {selectedToken}
+
handleEnableTranche(selectedToken)}
+ className="bg-blue-600 text-white py-2 px-6 rounded-lg font-medium hover:bg-blue-700"
+ >
+ Enable Tranche
+
+
+ )}
+
+
+ {/* 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 */}
+
+ 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 */}
+
+
+ Total Repayment Amount ($)
+
+
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 */}
+
+
+ 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 */}
+
+
+ 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 */}
+
+
+ Senior Target Yield (BPS)
+
+
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 */}
+
+
+ Elapsed Time (seconds)
+
+
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
+
+
+
+
+ Run Simulation
+
+
+
+
+ {/* 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
+
+
{
+ setTotalDue(1100);
+ setSeniorPrincipal(800);
+ setJuniorPrincipal(200);
+ setElapsedSecs(30 * 24 * 60 * 60);
+ }}
+ className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left"
+ >
+ Full Repayment
+ 110% of principal, 30 days elapsed
+
+
{
+ setTotalDue(500);
+ setSeniorPrincipal(800);
+ setJuniorPrincipal(200);
+ setElapsedSecs(15 * 24 * 60 * 60);
+ }}
+ className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left"
+ >
+ Partial Default
+ 50% recovery, 15 days elapsed
+
+
{
+ setTotalDue(0);
+ setSeniorPrincipal(800);
+ setJuniorPrincipal(200);
+ setElapsedSecs(60 * 24 * 60 * 60);
+ }}
+ className="p-4 border border-gray-200 rounded-lg hover:bg-gray-50 text-left"
+ >
+ Total Default
+ Zero recovery, 60 days elapsed
+
+
+
+
+
+ );
+}
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
+
+
+ )}
+
+
+
+ {available === 0 ? 'No Capacity' : `Deposit to ${isSenior ? 'Senior' : 'Junior'}`}
+
+
+ );
+}
+
+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
+
+
+
+
+ Select Token
+ setSelectedToken(e.target.value)}
+ className="block w-full max-w-xs rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
+ >
+ {TOKENS.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+
+
+
+
+ {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.
+ )}
+
+
+ Amount ({selectedToken})
+
+ 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"
+ />
+
+
+
+ {depositing ? 'Submitting…' : 'Confirm Deposit'}
+
+ setShowDepositModal(false)}
+ className="flex-1 bg-gray-200 text-gray-800 py-2 px-4 rounded-lg font-medium hover:bg-gray-300"
+ >
+ Cancel
+
+
+
+
+ )}
+
+
+ );
+}
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;
+}