Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ Update this file after every completed contract change, fix, or architectural de
- Extended `approve_loan()` to set `funded_at`, increment `user_active_debt`, lock pool funds via `fund_loan_from_pool`, write persistent loan record with TTL extension, and emit both `LOANAPPROVED` and `LOANFNDD` (`LoanFunded`) events.
- Added new unit tests covering: vendor funding and liquidity locking on approval, insufficient liquidity rejection (`InsufficientLiquidity`), suspended vendor approval rejection (`VendorNotActive`), decayed reputation approval rejection (`InsufficientReputation`), and double-funding prevention (`InvalidLoanStatus`).

### Issue #82 — First-Depositor Share-Price Inflation Attack Mitigation (revised after audit)
- **Dead shares with virtual backing**: On first deposit (`total_shares == 0`), mints `DEAD_SHARES_AMOUNT = 1_000` dead shares to `env.current_contract_address()` (unclaimable — `withdraw` requires `provider.require_auth()`). Dead shares are **backed by virtual liquidity**: `calculate_share_price_internal()` computes `(total_liquidity + DEAD_SHARES_AMOUNT) * PRECISION / total_shares` (floor `1`). This preserves honest 1:1 `deposit 1_000 → shares 1_000 → withdraw 1_000` while still preventing a dust depositor from owning 100% of yield (audit gap 1 fixed).
- **First-deposit share price**: No hardcoded branch. With virtual backing `(0+1_000)*10000/1_000 = 10_000`, the generic formula yields `PRECISION` for the first honest depositor, so share-price semantics are unchanged on the honest path.
- **`MIN_AMOUNT = 1_000`**: Deposits below `1_000` fail with `InvalidAmount` (#4). Enforced on all deposits; secondary dust defense. Honest `1_000` minimum depositor retains full principal (no 50% tax).
- **`calculate_share_price_internal` fix**: When `total_shares > 0` but `total_liquidity == 0` (fully-absorbed default), returns **near-zero proportional price** `(0+1_000)*10000/total_shares` (e.g. `909` for `10_000` shares + dead) floored to `1`, **not** hardcoded `0` (which bricked the next `deposit` via divide-by-zero) and **not** `PRECISION` (which hid losses). Coordinated with `absorb_loss()` caps (audit gap 3 fixed).
- **Withdraw guard**: `shares <= 0` rejected; floor-division in both `deposit` (`shares = amount*PRECISION/price` floored) and `withdraw` (`amount = shares*price/PRECISION` floored) via `safe_math`, never rounding up.
- **New constants**: `DEAD_SHARES_AMOUNT = 1_000`, `MIN_AMOUNT = 1_000`, `SHARE_PRICE_PRECISION = 10_000` in `types.rs`.
- **Tests — 107 pass**: Updated existing tests for virtual-backed math; `5` security tests corrected to `1:1`/`9_454`; plus **2 new regression**: `test_honest_path_regression_one_to_one` (proves `1_000→1_000` parity and second depositor `1:1`) and `test_post_default_deposit_does_not_brick` (full `absorb_loss` → price `909` → next `deposit 1_000` succeeds with `>1_000` shares, no divide-by-zero). Creditline integration `test_mark_defaulted_loss_absorption_share_price_impact` updated to `9_454`. Total `cargo test` 107 (liquidity-pool) + 128 (creditline) + others = 362 passing.
- **Honest-path reconciliation (audit gap)**: Strict `identical results` for *interest-bearing* yield is intentionally not preserved — dead shares (1_000) take a pro-rata `DEAD/(total+DEAD)` share of distributed interest as the cost of preventing the inflation attack. For the minimal `1_000` pool with `85` interest, honest withdraw `1085→1042` (−4.0%) and deposit-after-interest `921→959` (+4.1% shares for same 1_000) reflect this dilution. **Principal is strictly preserved** (`1_000→1_000` 1:1, second depositor `1:1` at same price, full drain leaves `0` real liquidity). For larger pools dilution is negligible (`10_000` pool, `85` interest → `1085` vs `1077` ≈0.7%). This bounded, documented trade-off is accepted: without dead shares an attacker could steal 100% of yield via dust-deposit + donation inflation. The alternative of smaller `DEAD` (e.g. 100) would reduce dilution to <1% but weaken dust protection; `1_000` equals `MIN_AMOUNT` for simplicity and is documented as deliberate.

### Timelocked Contract Upgrades & Version Overflow Safety
- Routed WASM upgrades through a mandatory two-step timelock (`propose_upgrade` → delay → `execute_upgrade`) across `liquidity-pool-contract`, `creditline-contract`, `reputation-contract`, and `vendor-registry-contract`.
- Parameterized upgrade delay via `ProtocolParameters` in `parameters-contract` (field `upgrade_delay_seconds: u64`, defaulting to 86,400 seconds / 1 day).
Expand Down Expand Up @@ -147,6 +157,14 @@ Update this file after every completed contract change, fix, or architectural de

### Issue #59 — Socialize Default Losses to Pool Share Price
- Added `absorb_loss(creditline, principal_shortfall)` entrypoint to `liquidity-pool-contract` restricted to the registered CreditLine

### Issue #79 — Admin Auth in Vendor Registry Initialize
- Added `admin.require_auth()` as the literal first line of `vendor_registry::initialize()` to prevent admin-hijack front-run attacks
- Reordered guard order to: `require_auth()` → `has_admin` check → state writes, consistent with `parameters-contract` and `liquidity-pool` patterns
- Wrote test proving unauthorized caller cannot complete initialization (auth failure, not just state rejection)
- Wrote test proving second `initialize()` call returns `AlreadyInitialized`
- All existing tests still pass; new test count has not decreased
- Fixed: No `.unwrap()` or `.expect()` introduced in user-facing paths
- Reduces both `locked_liquidity` and `total_liquidity` by the unrecovered principal, with independent caps to prevent negative accounting
- Added `LQLOSS` event (`emit_loss_absorbed`) to liquidity-pool events
- Updated `mark_defaulted()` to compute `principal_shortfall = principal_outstanding - guarantee_amount` and call `absorb_loss` after `receive_guarantee`
Expand Down
7 changes: 4 additions & 3 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4075,12 +4075,13 @@ fn test_mark_defaulted_loss_absorption_share_price_impact() {
cl_client.mark_defaulted(&loan_id);

let share_price_after = lp_client.get_pool_stats().share_price;
// Before default: total=10_000, shares=10_000, share_price=10_000
// Before default: total=10_000, shares=11_000 (10_000 + 1_000 dead), share_price=10_000
// (virtual backing: (10_000+1_000)*10000/11_000=10_000)
// After fund_loan(800): total=10_000, locked=800 (share_price unchanged)
// After receive_guarantee(200): total=10_200, locked=600
// After absorb_loss(800): total=9_400, locked=0
// share_price = 9_400 * 10_000 / 10_000 = 9_400
assert_eq!(share_price_after, 9_400);
// share_price = (9_400+1_000)*10_000/11_000 = 10_400*10000/11_000 = 9_454
assert_eq!(share_price_after, 9_454);

// Verify pool can still pay LP withdrawals (no unreachable locked liquidity)
let pool_stats = lp_client.get_pool_stats();
Expand Down
92 changes: 66 additions & 26 deletions contracts/liquidity-pool-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ impl LiquidityPoolContract {

/// Initialize the contract. Can only be called once.
///
/// * `admin` Contract administrator (can update addresses)
/// * `token` SEP-41 token used by the pool (e.g. USDC)
/// * `treasury` Address that receives the 10% protocol fee
/// * `merchant_fund` Address that receives the 5% merchant incentive fee
/// * `admin` ΓÇô Contract administrator (can update addresses)
/// * `token` ΓÇô SEP-41 token used by the pool (e.g. USDC)
/// * `treasury` ΓÇô Address that receives the 10% protocol fee
/// * `merchant_fund`ΓÇô Address that receives the 5% merchant incentive fee
pub fn initialize(
env: Env,
admin: Address,
Expand Down Expand Up @@ -78,7 +78,7 @@ impl LiquidityPoolContract {
storage::set_parameters_contract(&env, &address);
}

/// Propose a timelocked contract WASM upgrade admin only
/// Propose a timelocked contract WASM upgrade ΓÇö admin only
pub fn propose_upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();
Expand All @@ -98,7 +98,7 @@ impl LiquidityPoolContract {
events::emit_upgrade_proposed(&env, &new_wasm_hash, proposed_at, unlock_at);
}

/// Execute a previously proposed and timelocked contract WASM upgrade admin only
/// Execute a previously proposed and timelocked contract WASM upgrade ΓÇö admin only
pub fn execute_upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();
Expand Down Expand Up @@ -129,7 +129,7 @@ impl LiquidityPoolContract {
events::emit_contract_upgraded(&env, old_version, new_version);
}

/// Upgrade the contract WASM admin only (enforces prior propose_upgrade timelock)
/// Upgrade the contract WASM ΓÇö admin only (enforces prior propose_upgrade timelock)
pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
Self::execute_upgrade(env, new_wasm_hash);
}
Expand All @@ -148,7 +148,7 @@ impl LiquidityPoolContract {
/// Deposit `amount` tokens and receive shares representing pool ownership.
///
/// Shares are issued at the current share price:
/// `shares = (amount × PRECISION) / share_price`
/// `shares = (amount × PRECISION) / share_price`
///
/// For the first deposit share_price == PRECISION, so `shares == amount`.
///
Expand All @@ -162,7 +162,30 @@ impl LiquidityPoolContract {

Self::enter_non_reentrant(&env);

// Seed dead (unclaimable) shares on the very first deposit so a dust
// depositor cannot own 100 % of a yield-bearing pool.
let total_shares = storage::get_total_shares(&env)
.unwrap_or_else(|err| panic_with_error!(&env, err));
let is_first_deposit = total_shares == 0;

if is_first_deposit {
// Mint dead (unclaimable) shares to the contract itself ΓÇö nobody can
// withdraw them because `withdraw` requires `provider.require_auth()`.
//
// The dead shares are backed by an equal amount of *virtual* liquidity
// that is NOT stored in `total_liquidity` but is added in
// `calculate_share_price_internal` as `total_liquidity + DEAD_SHARES_AMOUNT`.
// This keeps the share price at PRECISION for the first honest depositor
// (1:1 share → token) so they are NOT taxed, while keeping visible
// `total_liquidity` equal to real tokens (preserving honest-path stats).
// The dead shares still prevent a dust depositor from owning 100% of yield.
storage::set_total_shares(&env, types::DEAD_SHARES_AMOUNT);
}

// With virtual backing the price is (total_liquidity+DEAD)*PRECISION/total_shares,
// which for the first deposit is (0+1000)*10000/1000 = 10000 (1:1).
let share_price = Self::calculate_share_price_internal(&env)?;
// Floor-division: depositor always receives fewer shares, never more.
let shares_issued = safe_math::div_i128(
safe_math::mul_i128(amount, types::SHARE_PRICE_PRECISION)?,
share_price,
Expand All @@ -180,7 +203,7 @@ impl LiquidityPoolContract {
let new_shares = safe_math::add_i128(provider_shares, shares_issued)?;
storage::set_lp_shares(&env, &provider, new_shares);

// Update total shares
// Update total shares (includes dead shares)
let total_shares = storage::get_total_shares(&env)
.unwrap_or_else(|err| panic_with_error!(&env, err));
let new_total_shares = safe_math::add_i128(total_shares, shares_issued)?;
Expand All @@ -204,13 +227,13 @@ impl LiquidityPoolContract {

/// Burn `shares` and return the proportional token amount to `provider`.
///
/// `amount = (shares × share_price) / PRECISION`
/// `amount = (shares × share_price) / PRECISION`
///
/// Returns the number of tokens returned.
pub fn withdraw(env: Env, provider: Address, shares: i128) -> Result<i128, LiquidityPoolError> {
provider.require_auth();

if shares < types::MIN_AMOUNT {
if shares <= 0 {
return Err(LiquidityPoolError::InvalidAmount);
}

Expand All @@ -223,6 +246,8 @@ impl LiquidityPoolContract {
}

let share_price = Self::calculate_share_price_internal(&env)?;
// Floor-division: the pool always keeps the rounding remainder ΓÇö
// the provider receives slightly fewer tokens, never more.
let amount_returned = safe_math::div_i128(
safe_math::mul_i128(shares, share_price)?,
types::SHARE_PRICE_PRECISION,
Expand Down Expand Up @@ -364,7 +389,7 @@ impl LiquidityPoolContract {
}
Self::enter_non_reentrant(&env);

// The defaulted loan principal stays "locked" the guarantee partially
// The defaulted loan principal stays "locked" ΓÇö the guarantee partially
// covers the loss. We reduce locked_liquidity by the guarantee amount
// and add it back to total_liquidity (net pool recovers that portion).
let locked =
Expand Down Expand Up @@ -426,9 +451,9 @@ impl LiquidityPoolContract {
}

/// Distribute `interest_amount` according to the protocol fee split:
/// - 85 % Liquidity Providers (increases share value by raising `total_liquidity`)
/// - 10 % Protocol Treasury
/// - 5 % Merchant Incentive Fund
/// - 85 % → Liquidity Providers (increases share value by raising `total_liquidity`)
/// - 10 % → Protocol Treasury
/// - 5 % → Merchant Incentive Fund
///
/// The LP portion is NOT transferred out; it stays in the pool and inflates
/// the share price (existing LP shares become worth more).
Expand Down Expand Up @@ -470,9 +495,9 @@ impl LiquidityPoolContract {
/// the accounting change occurs.
///
/// Fee split (same as `distribute_interest`):
/// - 85 % Liquidity Providers (share price increase)
/// - 10 % Protocol Treasury
/// - 5 % Merchant Incentive Fund
/// - 85 % → Liquidity Providers (share price increase)
/// - 10 % → Protocol Treasury
/// - 5 % → Merchant Incentive Fund
pub fn accumulate_interest(
env: Env,
creditline: Address,
Expand Down Expand Up @@ -506,19 +531,19 @@ impl LiquidityPoolContract {
types::TOTAL_BPS
);

// 85% stays in the pool increases share value
// 85% stays in the pool → increases share value
let lp_amount = safe_math::div_i128(
safe_math::mul_i128(interest_amount, types::LP_FEE_BPS)?,
types::TOTAL_BPS,
)?;

// 10% treasury
// 10% → treasury
let protocol_amount = safe_math::div_i128(
safe_math::mul_i128(interest_amount, types::PROTOCOL_FEE_BPS)?,
types::TOTAL_BPS,
)?;

// 5% merchant fund (use remainder to avoid rounding dust)
// 5% → merchant fund (use remainder to avoid rounding dust)
let merchant_amount = safe_math::sub_i128(
safe_math::sub_i128(interest_amount, lp_amount)?,
protocol_amount,
Expand Down Expand Up @@ -551,7 +576,7 @@ impl LiquidityPoolContract {
// If merchant fund not configured, fee stays in pool (benefits LPs)
}

// LP portion (lp_amount) stays in the pool no transfer needed.
// LP portion (lp_amount) stays in the pool ΓÇö no transfer needed.
// Update total_liquidity to reflect the added interest (raises share price).
let total_liquidity =
storage::get_total_liquidity(env).unwrap_or_else(|err| panic_with_error!(env, err));
Expand Down Expand Up @@ -626,13 +651,28 @@ impl LiquidityPoolContract {
fn calculate_share_price_internal(env: &Env) -> Result<i128, LiquidityPoolError> {
let total_shares = storage::get_total_shares(env)?;
let total_liquidity = storage::get_total_liquidity(env)?;
if total_shares == 0 || total_liquidity == 0 {

// Empty pool (no shares at all) ΓÇö price defaults to 1.0.
if total_shares == 0 {
return Ok(types::SHARE_PRICE_PRECISION);
}
safe_math::div_i128(
safe_math::mul_i128(total_liquidity, types::SHARE_PRICE_PRECISION)?,

// Virtual backing: dead shares are backed by an equal virtual liquidity
// amount that is NOT stored, but is added here. This keeps the initial
// price at 1:1 ( (0+1000)*10000/1000 = 10000 ) and makes post-default
// price proportional to real remaining liquidity without hardcoding 0
// (which would brick the next deposit). Floor to 1 prevents truncation to 0.
let effective_liquidity = safe_math::add_i128(total_liquidity, types::DEAD_SHARES_AMOUNT)?;

let price = safe_math::div_i128(
safe_math::mul_i128(effective_liquidity, types::SHARE_PRICE_PRECISION)?,
total_shares,
)
)?;
if price == 0 {
Ok(1)
} else {
Ok(price)
}
}

fn require_admin(env: &Env, caller: &Address) {
Expand Down
Loading
Loading