Skip to content

Commit fc5f13c

Browse files
committed
fix: PnL miscalculation for non-LPN collateral and RepayDialog crash
PnL: downpayment amount (LS_cltr_amnt_stable) uses collateral asset decimals, not LPN decimals. When collateral is BTC (8 decimals) but code divided by 10^6 (USDC), the downpayment was inflated ~100x, producing wildly wrong PnL values (e.g. -$990K instead of -$3K). RepayDialog: totalBalances.find() matched by ibcData across all protocols, returning a currency from a protocol without prices in pricesStore, causing "Cannot read properties of undefined" crash. Replaced with direct wallet balance lookup on the protocol-specific repayment currency.
1 parent dfb0cba commit fc5f13c

4 files changed

Lines changed: 100 additions & 36 deletions

File tree

backend/src/handlers/leases.rs

Lines changed: 80 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,10 @@ pub struct LeaseOpeningStateInfo {
160160

161161
#[derive(Debug, Clone, Serialize, Deserialize)]
162162
pub struct LeaseEtlData {
163-
/// Downpayment amount in USD
163+
/// Downpayment amount (LS_cltr_amnt_stable: asset_micro_units × price)
164164
pub downpayment_amount: Option<String>,
165+
/// Collateral symbol (e.g., "USDC_NOBLE" or "ALL_BTC") — determines downpayment decimals
166+
pub collateral_symbol: Option<String>,
165167
/// Opening price per asset
166168
pub price: Option<String>,
167169
/// LPN price at opening (for short positions)
@@ -738,6 +740,7 @@ async fn fetch_lease_info(
738740
// Note: Some fields are at the top level of EtlLeaseOpening, not inside lease
739741
let etl_info = etl_data.as_ref().map(|d| LeaseEtlData {
740742
downpayment_amount: d.lease.downpayment_amount.clone(),
743+
collateral_symbol: d.lease.collateral_symbol.clone(),
741744
// Opening price is inside lease.opening_price (was LS_opening_price)
742745
price: d.lease.opening_price.clone(),
743746
// lpn_price and fee are at the top level, not inside lease
@@ -1120,7 +1123,7 @@ fn parse_opened_status(status: &Option<serde_json::Value>) -> Option<LeaseInProg
11201123
/// Where:
11211124
/// - assetValueUsd = Dec(amount, assetDecimals) * assetPrice
11221125
/// - totalDebtUsd = Dec(debtTotal, lpnDecimals) * lpnPrice
1123-
/// - downPayment = Dec(etl.downpayment_amount, lpnDecimals)
1126+
/// - downPayment = Dec(etl.downpayment_amount, collateralDecimals)
11241127
/// - fee = Dec(etl.fee, assetDecimals)
11251128
/// - repaymentValue = parseFloat(etl.repayment_value) (already formatted)
11261129
fn calculate_pnl(
@@ -1163,7 +1166,20 @@ fn calculate_pnl(
11631166
let debt_total: f64 = total_debt.parse().ok()?;
11641167
let total_debt_usd = (debt_total / 10_f64.powi(lpn_decimals)) * lpn_price;
11651168

1166-
// Parse ETL data: downpayment uses LPN decimals
1169+
// Parse ETL data: downpayment uses collateral asset decimals.
1170+
// LS_cltr_amnt_stable = asset_micro_units × price, so the decimal factor
1171+
// comes from the collateral asset, not LPN (they differ when the user pays
1172+
// the downpayment in a non-LPN asset like BTC).
1173+
let cltr_decimals = etl
1174+
.lease
1175+
.collateral_symbol
1176+
.as_deref()
1177+
.and_then(|cltr_ticker| {
1178+
let cltr_key = format!("{}@{}", cltr_ticker, protocol);
1179+
currencies.currencies.get(&cltr_key).map(|c| c.decimal_digits as i32)
1180+
})
1181+
.unwrap_or(lpn_decimals);
1182+
11671183
// None legitimately means 0 (no downpayment recorded); parse failure is a data issue
11681184
let downpayment_raw: f64 = match etl.lease.downpayment_amount.as_deref() {
11691185
Some(s) => s.parse().unwrap_or_else(|_| {
@@ -1175,7 +1191,7 @@ fn calculate_pnl(
11751191
}),
11761192
None => 0.0,
11771193
};
1178-
let downpayment = downpayment_raw / 10_f64.powi(lpn_decimals);
1194+
let downpayment = downpayment_raw / 10_f64.powi(cltr_decimals);
11791195

11801196
// Fee uses asset decimals (matching frontend behavior)
11811197
let fee_raw: f64 = match etl.fee.as_deref() {
@@ -1646,13 +1662,22 @@ mod tests {
16461662
}
16471663

16481664
fn make_etl(downpayment: &str, fee: &str, repayment_value: &str) -> EtlLeaseOpening {
1665+
make_etl_with_collateral(downpayment, fee, repayment_value, None)
1666+
}
1667+
1668+
fn make_etl_with_collateral(
1669+
downpayment: &str,
1670+
fee: &str,
1671+
repayment_value: &str,
1672+
collateral_symbol: Option<&str>,
1673+
) -> EtlLeaseOpening {
16491674
EtlLeaseOpening {
16501675
lease: EtlLeaseInfo {
16511676
timestamp: None,
16521677
downpayment_amount: Some(downpayment.to_string()),
16531678
loan_amount: None,
16541679
lease_position_ticker: None,
1655-
collateral_symbol: None,
1680+
collateral_symbol: collateral_symbol.map(|s| s.to_string()),
16561681
opening_price: None,
16571682
history: None,
16581683
},
@@ -1857,6 +1882,56 @@ mod tests {
18571882
assert!(result.is_none());
18581883
}
18591884

1885+
#[test]
1886+
fn test_calculate_pnl_btc_collateral() {
1887+
// Reproduces the bug: user pays downpayment in BTC, not USDC.
1888+
// LS_cltr_amnt_stable = asset_micro × price = 13200000 × 75583.85 ≈ 997706865842
1889+
// Correct: divide by 10^8 (BTC decimals) = $9977.07
1890+
// Bug: divide by 10^6 (USDC decimals) = $997706.87 (100x too large)
1891+
//
1892+
// BTC at $90,000, holding 0.46 BTC = $41,400
1893+
// Debt: 14974 USDC at $1.00 = $14,974
1894+
// Downpayment: 0.132 BTC at $75,583.85 → LS_cltr_amnt_stable = 997706865842
1895+
// Correct downpayment = 997706865842 / 10^8 = $9977.07
1896+
// Repayment: $1000
1897+
// PnL = 41400 - 14974 - 9977.07 + 0 - 1000 = $15448.93
1898+
// PnL% = 15448.93 / (9977.07 + 1000) * 100 = 140.77%
1899+
let opened = make_opened("ALL_BTC", "46000000", "USDC_NOBLE"); // 0.46 BTC
1900+
let etl = make_etl_with_collateral(
1901+
"997706865842", // LS_cltr_amnt_stable (asset_micro × price)
1902+
"0",
1903+
"1000",
1904+
Some("ALL_BTC"), // collateral is BTC, not USDC
1905+
);
1906+
let (prices, currencies) = make_prices_and_currencies("90000", "1.0");
1907+
1908+
let result = calculate_pnl(
1909+
"TEST-PROTOCOL",
1910+
&opened,
1911+
"14974000000", // ~14974 USDC debt
1912+
Some(&etl),
1913+
Some(&prices),
1914+
Some(&currencies),
1915+
);
1916+
1917+
let pnl = result.unwrap();
1918+
let amount: f64 = pnl.amount.parse().unwrap();
1919+
let percent: f64 = pnl.percent.parse().unwrap();
1920+
// With correct BTC decimals (8): downpayment = $9977.07
1921+
// PnL ≈ $15448.93, PnL% ≈ 140.77%
1922+
assert!(
1923+
(amount - 15448.93).abs() < 1.0,
1924+
"PnL amount should be ~$15449 but got {}",
1925+
amount
1926+
);
1927+
assert!(
1928+
(percent - 140.77).abs() < 1.0,
1929+
"PnL percent should be ~140.8% but got {}",
1930+
percent
1931+
);
1932+
assert!(pnl.pnl_positive);
1933+
}
1934+
18601935
#[test]
18611936
fn test_enrich_history_action_liquidation_with_cause() {
18621937
assert_eq!(

src/common/api/types/leases.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,10 @@ export interface LeaseOpeningStateInfo {
8484
}
8585

8686
export interface LeaseEtlData {
87-
/** Downpayment amount in USD */
87+
/** Downpayment amount (LS_cltr_amnt_stable: asset_micro_units × price) */
8888
downpayment_amount?: string;
89+
/** Collateral symbol (e.g., "USDC_NOBLE" or "ALL_BTC") — determines downpayment decimals */
90+
collateral_symbol?: string;
8991
/** Opening price per asset */
9092
price?: string;
9193
/** LPN price at opening (for short positions) */

src/common/utils/LeaseCalculator.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,17 @@ export class LeaseCalculator {
118118
const { health, healthStatus } = this.calculateHealth(lease, assetValueUsd, totalDebtUsd);
119119

120120
// Parse ETL data (needed for PnL calculation)
121-
// Note: downpayment uses LPN decimals, fee uses asset decimals (to match production)
121+
// Downpayment uses collateral asset decimals (LS_cltr_amnt_stable = asset_micro × price),
122+
// fee uses leased asset decimals.
123+
const collateralDecimals = lease.etl_data?.collateral_symbol
124+
? (this.currencyProvider.getCurrency(lease.etl_data.collateral_symbol, protocol)?.decimal_digits ??
125+
lpnCurrency?.decimal_digits ??
126+
6)
127+
: (lpnCurrency?.decimal_digits ?? 6);
122128
const { downPayment, openingPrice, fee, repaymentValue } = this.parseEtlData(
123129
lease,
124130
positionType,
125-
lpnCurrency?.decimal_digits ?? 6,
131+
collateralDecimals,
126132
currency?.decimal_digits ?? 8
127133
);
128134

@@ -365,13 +371,13 @@ export class LeaseCalculator {
365371

366372
/**
367373
* Parse ETL data from lease
368-
* @param lpnDecimals - decimals for LPN currency (used for downpayment)
369-
* @param assetDecimals - decimals for the leased asset (used for fee, matching production behavior)
374+
* @param collateralDecimals - decimals for the collateral asset (used for downpayment)
375+
* @param assetDecimals - decimals for the leased asset (used for fee)
370376
*/
371377
parseEtlData(
372378
lease: LeaseInfo,
373379
positionType: string,
374-
lpnDecimals: number,
380+
collateralDecimals: number,
375381
assetDecimals: number
376382
): {
377383
downPayment: Dec;
@@ -380,7 +386,7 @@ export class LeaseCalculator {
380386
repaymentValue: Dec;
381387
} {
382388
const downPayment = lease.etl_data?.downpayment_amount
383-
? new Dec(lease.etl_data.downpayment_amount, lpnDecimals)
389+
? new Dec(lease.etl_data.downpayment_amount, collateralDecimals)
384390
: new Dec(0);
385391

386392
const openingPrice =

src/modules/leases/components/single-lease/RepayDialog.vue

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ const balances = computed(() => {
273273
let repaymentCurrency;
274274
275275
if (positionType === "Short") {
276-
// Short: debt is in the underlying asset (e.g. ATOM), use debt.ticker
276+
// Short: debt is in LPN (e.g. USDC_NOBLE), use debt.ticker
277277
const debtTicker = lease.value.debt.ticker;
278278
repaymentCurrency = configStore.currenciesData![`${debtTicker}@${lease.value.protocol}`];
279279
} else {
@@ -283,29 +283,10 @@ const balances = computed(() => {
283283
284284
if (!repaymentCurrency) return [];
285285
286-
// Look for the repayment currency in wallet balances
287-
const match = totalBalances.value.find((item) => item.ibcData === repaymentCurrency!.ibcData);
288-
if (match) return [match];
289-
290-
// Always show the currency even with zero balance so the user can see what to repay with
291-
const zeroCurrency = { ...repaymentCurrency, balance: { denom: repaymentCurrency.ibcData, amount: "0" } };
292-
return [zeroCurrency];
293-
});
294-
295-
const totalBalances = computed(() => {
296-
const assets = [];
297-
298-
for (const key in configStore.currenciesData ?? {}) {
299-
const currency = configStore.currenciesData![key];
300-
const c = { ...currency };
301-
const item = balancesStore.balances.find((item) => item.denom == currency.ibcData);
302-
if (item) {
303-
c.balance = item;
304-
assets.push(c);
305-
}
306-
}
307-
308-
return assets;
286+
// Use the protocol-specific currency directly (not a cross-protocol find by ibcData)
287+
// to ensure the .key matches the price entry in pricesStore.
288+
const walletBalance = balancesStore.balances.find((item) => item.denom === repaymentCurrency!.ibcData);
289+
return [{ ...repaymentCurrency, balance: walletBalance ?? { denom: repaymentCurrency.ibcData, amount: "0" } }];
309290
});
310291
311292
function handleAmountChange(event: string) {

0 commit comments

Comments
 (0)