fix: KaminoMarket.load() was called with a garbage argument in every position, breaking all Kamino lending

Audit of src/solana/kamino.ts, part of the ongoing Solana venue-integration
coverage pass. The most severe finding of this audit series: KaminoMarket
.load()'s real signature is (connection, marketAddress, recentSlotDurationMs:
number, programId?, setupLocalTest?, withReserves?) — verified against the
SDK's own market.d.ts — but every one of the 6 call sites (getKaminoMarkets,
getKaminoObligation, depositToKamino, withdrawFromKamino, borrowFromKamino,
repayToKamino) passed PROGRAM_ID as the third argument (where a number was
expected) and never passed a real programId at all. The `as any` cast on
the destructured import silenced TypeScript's arity check entirely, so this
was invisible to typecheck.

Confirmed live in two layers:
- With the wrong arg order, getKaminoMarkets() always returned [] — first
  traced to calculateSupplyAPR()/calculateBorrowAPR() throwing
  "[DecimalError] Invalid argument: undefined" for every single reserve,
  no exceptions.
- Fixing just that arity (those two methods need (slot, referralFeeBps),
  not zero args) surfaced the real, deeper bug: a different error,
  "Invalid borrow rate curve, could not identify the interpolation
  points," again on every reserve — because recentSlotDurationMs was
  receiving a garbage PublicKey object instead of a real millisecond
  duration, breaking the SDK's internal rate-curve interpolation
  entirely regardless of which reserve.

Fixed by passing the SDK's own DEFAULT_RECENT_SLOT_DURATION_MS (=450) as
the third argument and PROGRAM_ID as the fourth, at all 6 call sites.
After the fix, getKaminoMarkets() returns all 58 real reserves with
realistic live rates (e.g. SOL: 5.05% deposit / 6.6% borrow / 89.9%
utilization / 74% LTV). This bug affected every Kamino lending function in
the file, not just the read path — deposit/withdraw/borrow/repay all load
the market the same way.

Also added a per-reserve try/catch in getKaminoMarkets() (matching the
per-item pattern getKaminoStrategies() below already uses) so one
genuinely malformed reserve can't blank out the whole result the way this
bug did.

Known, non-blocking follow-up not addressed here: getKaminoStrategies/
getKaminoStrategy/getKaminoUserShares (the kliquidity-sdk vault side)
hardcode tvl:'0', apy:0, tokenASymbol:'TokenA', tokenBSymbol:'TokenB', and
every user position amount to '0', despite the SDK exposing real methods
for this data (getStrategyAprApy, getStrategyShareData,
getStrategyTokensHoldings) — confirmed these exist via the SDK's Kamino.d.ts.
Not fixed here since properly wiring up TVL/APY/token-amount computation
across three functions is a separate, larger scope than this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
alsk1992
2026-09-01 17:10:25 +01:00
co-authored by Claude Sonnet 5
parent 3520347d44
commit c6b0b7d944
+50 -20
View File
@@ -156,11 +156,21 @@ export async function getKaminoMarkets(
connection: Connection
): Promise<KaminoMarketInfo[]> {
try {
const { KaminoMarket, PROGRAM_ID } = await import('@kamino-finance/klend-sdk');
const { KaminoMarket, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } = await import('@kamino-finance/klend-sdk');
// KaminoMarket.load()'s real signature is (connection, marketAddress,
// recentSlotDurationMs: number, programId?, ...) — verified against the
// SDK's own market.d.ts. Every call site here used to pass PROGRAM_ID
// as the third argument (a PublicKey where a number was expected) and
// never passed a real programId at all, since the `as any` cast
// silenced TypeScript's arity/type check. Confirmed live: this was the
// actual root cause of "[DecimalError] Invalid argument: undefined" and
// "Invalid borrow rate curve, could not identify the interpolation
// points" errors on every single reserve.
const market = await KaminoMarket.load(
connection,
new PublicKey(KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);
@@ -168,22 +178,37 @@ export async function getKaminoMarkets(
return [];
}
// calculateSupplyAPR()/calculateBorrowAPR() require (slot, referralFeeBps)
// — confirmed against the SDK's own reserve.d.ts. The old code called
// them with zero arguments (the `as any` cast on `reserve` silenced
// TypeScript's arity check), which threw "[DecimalError] Invalid
// argument: undefined" for every single reserve, aborted this loop, and
// bubbled up to the outer catch — meaning getKaminoMarkets() always
// silently returned [] regardless of real reserve data. 0 referral bps
// (no referral program integration here). The per-reserve try/catch
// stays as defense against any genuinely malformed reserve, matching
// the per-item pattern getKaminoStrategies() below already uses.
const currentSlot = await connection.getSlot();
const reserves: KaminoReserveInfo[] = [];
for (const [, reserve] of market.reserves) {
reserves.push({
address: reserve.address.toBase58(),
symbol: reserve.symbol || 'UNKNOWN',
mint: reserve.getLiquidityMint().toBase58(),
decimals: (reserve.state.liquidity.mintDecimals as BN).toNumber(),
depositRate: (reserve as any).calculateSupplyAPR() * 100,
borrowRate: (reserve as any).calculateBorrowAPR() * 100,
totalDeposits: reserve.getTotalSupply().toString(),
totalBorrows: reserve.getBorrowedAmount().toString(),
availableLiquidity: reserve.getLiquidityAvailableAmount().toString(),
utilizationRate: reserve.calculateUtilizationRatio() * 100,
ltv: reserve.state.config.loanToValuePct,
liquidationThreshold: reserve.state.config.liquidationThresholdPct,
});
try {
reserves.push({
address: reserve.address.toBase58(),
symbol: reserve.symbol || 'UNKNOWN',
mint: reserve.getLiquidityMint().toBase58(),
decimals: (reserve.state.liquidity.mintDecimals as BN).toNumber(),
depositRate: (reserve as any).calculateSupplyAPR(currentSlot, 0) * 100,
borrowRate: (reserve as any).calculateBorrowAPR(currentSlot, 0) * 100,
totalDeposits: reserve.getTotalSupply().toString(),
totalBorrows: reserve.getBorrowedAmount().toString(),
availableLiquidity: reserve.getLiquidityAvailableAmount().toString(),
utilizationRate: reserve.calculateUtilizationRatio() * 100,
ltv: reserve.state.config.loanToValuePct,
liquidationThreshold: reserve.state.config.liquidationThresholdPct,
});
} catch (error) {
logger.warn({ error, reserve: reserve.address.toBase58() }, 'Skipping Kamino reserve that failed to load');
}
}
return [{
@@ -227,11 +252,12 @@ export async function getKaminoObligation(
marketAddress?: string
): Promise<KaminoObligationInfo | null> {
try {
const { KaminoMarket, VanillaObligation, PROGRAM_ID } = await import('@kamino-finance/klend-sdk');
const { KaminoMarket, VanillaObligation, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } = await import('@kamino-finance/klend-sdk');
const market = await KaminoMarket.load(
connection,
new PublicKey(marketAddress || KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);
@@ -309,12 +335,13 @@ export async function depositToKamino(
keypair: Keypair,
params: KaminoDepositParams
): Promise<KaminoLendingResult> {
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID } =
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } =
await import('@kamino-finance/klend-sdk');
const market = await KaminoMarket.load(
connection,
new PublicKey(params.marketAddress || KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);
@@ -366,12 +393,13 @@ export async function withdrawFromKamino(
keypair: Keypair,
params: KaminoWithdrawParams
): Promise<KaminoLendingResult> {
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID } =
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } =
await import('@kamino-finance/klend-sdk');
const market = await KaminoMarket.load(
connection,
new PublicKey(params.marketAddress || KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);
@@ -423,12 +451,13 @@ export async function borrowFromKamino(
keypair: Keypair,
params: KaminoBorrowParams
): Promise<KaminoLendingResult> {
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID } =
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } =
await import('@kamino-finance/klend-sdk');
const market = await KaminoMarket.load(
connection,
new PublicKey(params.marketAddress || KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);
@@ -480,12 +509,13 @@ export async function repayToKamino(
keypair: Keypair,
params: KaminoRepayParams
): Promise<KaminoLendingResult> {
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID } =
const { KaminoMarket, KaminoAction, VanillaObligation, PROGRAM_ID, DEFAULT_RECENT_SLOT_DURATION_MS } =
await import('@kamino-finance/klend-sdk');
const market = await KaminoMarket.load(
connection,
new PublicKey(params.marketAddress || KAMINO_MAIN_MARKET),
DEFAULT_RECENT_SLOT_DURATION_MS,
PROGRAM_ID as any
);