diff --git a/composables/borrow/useBorrowForm.ts b/composables/borrow/useBorrowForm.ts index 3c34a64e..429dad05 100644 --- a/composables/borrow/useBorrowForm.ts +++ b/composables/borrow/useBorrowForm.ts @@ -34,7 +34,7 @@ import type { TxPlan } from '~/entities/txPlan' import { getPlanHookDisabledWarning, getUtilisationWarning, getBorrowCapWarning, getSupplyCapWarning } from '~/composables/useVaultWarnings' import { getVaultTags, isVaultRestrictedByCountry, isAssetBlockedByCountry } from '~/composables/useGeoBlock' import { useSwapQuotesParallel } from '~/composables/useSwapQuotesParallel' -import { getNetAPY, getProjectedRates } from '~/entities/vault' +import { getNetAPY, getProjectedRatesBatch } from '~/entities/vault' import { findBlockingDisabledOp, OP_BORROW, OP_DEPOSIT, OP_SKIM, OP_TRANSFER, type PlannedOp } from '~/utils/vault-hooks' export interface UseBorrowFormOptions { @@ -531,26 +531,29 @@ export const useBorrowForm = (options: UseBorrowFormOptions) => { : valueToNano(collateralAmount.value || '0', collateralVault.value.decimals) const borrowAmountNano = valueToNano(borrowAmount.value || '0', borrowVault.value.decimals) - const [collateralProjected, borrowProjected, collateralUsdValue, borrowUsdValue] = await Promise.all([ - getProjectedRates( - collateralVault.value.address, - collateralVault.value.interestRateInfo.cash, - collateralVault.value.interestRateInfo.borrows, - collateralAmountNano, - 0n, - ), - getProjectedRates( - borrowVault.value.address, - borrowVault.value.interestRateInfo.cash, - borrowVault.value.interestRateInfo.borrows, - -borrowAmountNano, - borrowAmountNano, - ), + const [projectedRates, collateralUsdValue, borrowUsdValue] = await Promise.all([ + getProjectedRatesBatch([ + { + vaultAddress: collateralVault.value.address, + currentCash: collateralVault.value.interestRateInfo.cash, + currentBorrows: collateralVault.value.interestRateInfo.borrows, + cashDelta: collateralAmountNano, + borrowsDelta: 0n, + }, + { + vaultAddress: borrowVault.value.address, + currentCash: borrowVault.value.interestRateInfo.cash, + currentBorrows: borrowVault.value.interestRateInfo.borrows, + cashDelta: -borrowAmountNano, + borrowsDelta: borrowAmountNano, + }, + ]), borrowNeedsSwap.value && borrowSwapAssetUsdPrice.value ? Promise.resolve((+collateralAmount.value || 0) * borrowSwapAssetUsdPrice.value) : getAssetUsdValueOrZero(collateralAmountNano, collateralVault.value!, 'off-chain'), getAssetUsdValueOrZero(borrowAmountNano, borrowVault.value!, 'off-chain'), ]) + const [collateralProjected, borrowProjected] = projectedRates if (asyncEstimatesGuard.isStale(gen)) return diff --git a/composables/borrow/useMultiplyForm.ts b/composables/borrow/useMultiplyForm.ts index 6ca3bab2..20b13b47 100644 --- a/composables/borrow/useMultiplyForm.ts +++ b/composables/borrow/useMultiplyForm.ts @@ -12,7 +12,8 @@ import { type Vault, type ProjectedRates, convertAssetsToShares, - getProjectedRates, + getProjectedRatesBatch, + getRoe, } from '~/entities/vault' import { getAssetUsdValue, @@ -27,7 +28,7 @@ import { buildSwapRouteItems } from '~/utils/swapRouteItems' import { formatSmartAmount, trimTrailingZeros } from '~/utils/string-utils' import { nanoToValue } from '~/utils/crypto-utils' import { computeMultipliedPriceImpact } from '~/utils/priceImpact' -import { calculateRoe, computeNextHealth, computeLiquidationPrice } from '~/utils/repayUtils' +import { computeNextHealth, computeLiquidationPrice } from '~/utils/repayUtils' import { computeMaxMultiplier, computeMinMultiplier, computeWeightedSupplyApy, computeLeverageDebt } from '~/utils/multiply-math' import type { TxPlan } from '~/entities/txPlan' import { getPlanHookDisabledWarning, getUtilisationWarning, getBorrowCapWarning } from '~/composables/useVaultWarnings' @@ -302,9 +303,9 @@ export const useMultiplyForm = (options: UseMultiplyFormOptions) => { if (supplyAndLongSameVault) { // Combined delta for supply + long vault - const [combined, shortResult] = await Promise.all([ - getProjectedRates(supply.address, supply.interestRateInfo.cash, supply.interestRateInfo.borrows, supplyNano + swapOut, 0n), - getProjectedRates(short.address, short.interestRateInfo.cash, short.interestRateInfo.borrows, -debtNano, debtNano), + const [combined, shortResult] = await getProjectedRatesBatch([ + { vaultAddress: supply.address, currentCash: supply.interestRateInfo.cash, currentBorrows: supply.interestRateInfo.borrows, cashDelta: supplyNano + swapOut, borrowsDelta: 0n }, + { vaultAddress: short.address, currentCash: short.interestRateInfo.cash, currentBorrows: short.interestRateInfo.borrows, cashDelta: -debtNano, borrowsDelta: debtNano }, ]) if (projectedRatesGuard.isStale(gen)) return projectedSupplyRates.value = combined @@ -312,10 +313,10 @@ export const useMultiplyForm = (options: UseMultiplyFormOptions) => { projectedBorrowRates.value = shortResult } else { - const [supplyResult, shortResult, longResult] = await Promise.all([ - getProjectedRates(supply.address, supply.interestRateInfo.cash, supply.interestRateInfo.borrows, supplyNano, 0n), - getProjectedRates(short.address, short.interestRateInfo.cash, short.interestRateInfo.borrows, -debtNano, debtNano), - getProjectedRates(long.address, long.interestRateInfo.cash, long.interestRateInfo.borrows, swapOut, 0n), + const [supplyResult, shortResult, longResult] = await getProjectedRatesBatch([ + { vaultAddress: supply.address, currentCash: supply.interestRateInfo.cash, currentBorrows: supply.interestRateInfo.borrows, cashDelta: supplyNano, borrowsDelta: 0n }, + { vaultAddress: short.address, currentCash: short.interestRateInfo.cash, currentBorrows: short.interestRateInfo.borrows, cashDelta: -debtNano, borrowsDelta: debtNano }, + { vaultAddress: long.address, currentCash: long.interestRateInfo.cash, currentBorrows: long.interestRateInfo.borrows, cashDelta: swapOut, borrowsDelta: 0n }, ]) if (projectedRatesGuard.isStale(gen)) return projectedSupplyRates.value = supplyResult @@ -373,8 +374,7 @@ export const useMultiplyForm = (options: UseMultiplyFormOptions) => { // --- ROE --- const multiplyRoeBefore = computed(() => { if (isMultiplyQuoteLoading.value) return null - if (multiplySupplyValueUsd.value === null) return null - return 0 + return null }) const multiplyRoeAfter = computed(() => { @@ -385,10 +385,10 @@ export const useMultiplyForm = (options: UseMultiplyFormOptions) => { || multiplyWeightedSupplyApy.value === null || multiplyBorrowApy.value === null ) return null - return calculateRoe( + return getRoe( multiplyTotalSupplyUsd.value, - multiplyBorrowValueUsd.value, multiplyWeightedSupplyApy.value, + multiplyBorrowValueUsd.value, multiplyBorrowApy.value, ) }) diff --git a/composables/position/useCollateralForm.ts b/composables/position/useCollateralForm.ts index 9db90ab0..e4c11561 100644 --- a/composables/position/useCollateralForm.ts +++ b/composables/position/useCollateralForm.ts @@ -10,8 +10,7 @@ import { OperationReviewModal, SwapTokenSelector, SlippageSettingsModal } from ' import { useToast } from '~/components/ui/composables/useToast' import { eulerAccountLensABI } from '~/entities/euler/abis' import { - getNetAPY, - getProjectedRates, + getNetAPYFromWeightedSupplySnapshot, isEVKVault, type Vault, type SecuritizeVault, @@ -19,7 +18,6 @@ import { } from '~/entities/vault' import { getAssetUsdValueOrZero, - getCollateralUsdValueOrZero, } from '~/services/pricing/priceProvider' import type { TxPlan } from '~/entities/txPlan' import { isAnyVaultBlockedByCountry, isVaultRestrictedByCountry, isAssetBlockedByCountry, isAssetRestrictedByCountry } from '~/composables/useGeoBlock' @@ -126,6 +124,7 @@ export const useCollateralForm = (options: UseCollateralFormOptions) => { const { getOrFetch } = useVaultRegistry() const { eulerLensAddresses, isReady: isEulerAddressesReady, loadEulerConfig } = useEulerAddresses() const { client: rpcClient } = useRpcClient() + const { getCollateralApySnapshot } = usePositionCollateralApy() // --- Shared reactive state --- const isLoading = ref(false) @@ -189,11 +188,6 @@ export const useCollateralForm = (options: UseCollateralFormOptions) => { borrowVault.value?.asset.address, )) - const getCollateralValueUsdLocal = async (amt: bigint) => { - if (!borrowVault.value || !collateralVault.value) return 0 - return getCollateralUsdValueOrZero(amt, borrowVault.value, collateralVault.value as Vault, 'off-chain') - } - const netAPY = ref(0) watchEffect(async () => { @@ -202,13 +196,13 @@ export const useCollateralForm = (options: UseCollateralFormOptions) => { return } - const [collateralUsd, borrowedUsd] = await Promise.all([ - getCollateralValueUsdLocal(collateralAssets.value), + const [collateralSnapshot, borrowedUsd] = await Promise.all([ + getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(position.value.borrowed ?? 0n, borrowVault.value, 'off-chain'), ]) - netAPY.value = getNetAPY( - collateralUsd, + netAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, collateralSupplyApy.value, borrowedUsd, borrowApy.value, @@ -530,7 +524,7 @@ export const useCollateralForm = (options: UseCollateralFormOptions) => { const asyncEstimatesGuard = createRaceGuard() const updateAsyncEstimates = useDebounceFn(async () => { - if (!collateralVault.value || !borrowVault.value) { + if (!position.value || !collateralVault.value || !borrowVault.value) { isEstimatesLoading.value = false return } @@ -539,31 +533,22 @@ export const useCollateralForm = (options: UseCollateralFormOptions) => { const amountNano = valueToNano(amount.value, collateralVault.value.decimals) const cashDelta = options.mode === 'supply' ? amountNano : -amountNano - const [projected, collateralUsd, borrowedUsd] = await Promise.all([ - getProjectedRates( - collateralVault.value.address, - collateralVault.value.interestRateInfo.cash, - collateralVault.value.interestRateInfo.borrows, - cashDelta, - 0n, - ), - getCollateralValueUsdLocal( - options.mode === 'supply' - ? collateralAssets.value + amountNano - : collateralAssets.value - amountNano, - ), + const [collateralSnapshot, borrowedUsd] = await Promise.all([ + getCollateralApySnapshot(position.value, borrowVault.value, { + deltas: [{ + vaultAddress: collateralVault.value.address, + assetsDelta: cashDelta, + projectRates: true, + }], + }), getAssetUsdValueOrZero(position.value!.borrowed || 0n, borrowVault.value!, 'off-chain'), ]) if (asyncEstimatesGuard.isStale(gen)) return - const projectedSupplyApy = projected - ? withIntrinsicSupplyApy(nanoToValue(projected.supplyAPY, 25), collateralVault.value?.asset.address) - : collateralSupplyApy.value - - estimateNetAPY.value = getNetAPY( - collateralUsd, - projectedSupplyApy, + estimateNetAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, + collateralSupplyApy.value, borrowedUsd, borrowApy.value, collateralSupplyRewardApy.value || null, diff --git a/composables/repay/useCollateralSwapRepay.ts b/composables/repay/useCollateralSwapRepay.ts index 4ac2f5f0..c03a07e3 100644 --- a/composables/repay/useCollateralSwapRepay.ts +++ b/composables/repay/useCollateralSwapRepay.ts @@ -6,7 +6,7 @@ import { useModal } from '~/components/ui/composables/useModal' import { OperationReviewModal } from '#components' import { useToast } from '~/components/ui/composables/useToast' import { getCashLimitedWithdrawAmount, isEVKVault, type Vault } from '~/entities/vault' -import { getAssetUsdValue, getAssetOraclePrice, conservativePriceRatioNumber } from '~/services/pricing/priceProvider' +import { getAssetOraclePrice, conservativePriceRatioNumber } from '~/services/pricing/priceProvider' import type { AccountBorrowPosition } from '~/entities/account' import type { TxPlan } from '~/entities/txPlan' import { SwapperMode } from '~/entities/swap' @@ -65,6 +65,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const { client: rpcClient } = useRpcClient() const { withIntrinsicSupplyApy, withIntrinsicBorrowApy } = useIntrinsicApy() const { getSupplyRewardApy, getBorrowRewardApy } = useRewardsApy() + const { getCollateralApySnapshot } = usePositionCollateralApy() // --- Source vault state --- const sourceVault: Ref = ref() @@ -171,21 +172,41 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = return nanoToValue(position.value.liquidationLTV, 2) }) - // --- 4th USD watcher: next collateral value --- - const nextCollateralUsdGuard = createRaceGuard() + // --- Collateral portfolio value/APY --- + const collateralPortfolioGuard = createRaceGuard() + const weightedCollateralSupplyApy = ref(null) + const nextWeightedCollateralSupplyApy = ref(null) + const collateralValueUsd = ref(null) const nextCollateralValueUsd = ref(null) watchEffect(async () => { - if (!sourceVault.value || core.spent.value === null) { + if (!position.value || !borrowVault.value || !sourceVault.value) { + weightedCollateralSupplyApy.value = null + nextWeightedCollateralSupplyApy.value = null + collateralValueUsd.value = null nextCollateralValueUsd.value = null return } - const gen = nextCollateralUsdGuard.next() - const nextAssets = sourceAssets.value - core.spent.value - const result = (await getAssetUsdValue(nextAssets > 0n ? nextAssets : 0n, sourceVault.value, 'off-chain')) ?? null - if (nextCollateralUsdGuard.isStale(gen)) return - nextCollateralValueUsd.value = result + const gen = collateralPortfolioGuard.next() + const spent = core.spent.value ?? 0n + const [currentSnapshot, nextSnapshot] = await Promise.all([ + getCollateralApySnapshot(position.value, borrowVault.value), + getCollateralApySnapshot(position.value, borrowVault.value, { + deltas: [{ + vaultAddress: sourceVault.value.address, + assetsDelta: -spent, + projectRates: spent > 0n, + }], + }), + ]) + if (collateralPortfolioGuard.isStale(gen)) return + weightedCollateralSupplyApy.value = currentSnapshot.weightedSupplyApy + nextWeightedCollateralSupplyApy.value = nextSnapshot.weightedSupplyApy + collateralValueUsd.value = currentSnapshot.supplyUsd + nextCollateralValueUsd.value = nextSnapshot.supplyUsd }) + const effectiveCollateralSupplyApy = computed(() => weightedCollateralSupplyApy.value ?? collateralSupplyApy.value) + const effectiveNextCollateralSupplyApy = computed(() => nextWeightedCollateralSupplyApy.value ?? effectiveCollateralSupplyApy.value) // --- Health metrics --- const health = useRepayHealthMetrics({ @@ -195,9 +216,10 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = priceRatio, nextLiquidationLtv, collateralAmountAfter, - collateralSupplyApy, + collateralSupplyApy: effectiveCollateralSupplyApy, + nextCollateralSupplyApy: effectiveNextCollateralSupplyApy, borrowApy, - collateralValueUsd: core.sourceValueUsd, + collateralValueUsd, nextCollateralValueUsd, borrowValueUsd: core.borrowValueUsd, nextBorrowValueUsd: core.nextBorrowValueUsd, diff --git a/composables/repay/useRepayHealthMetrics.ts b/composables/repay/useRepayHealthMetrics.ts index edd6a371..72467141 100644 --- a/composables/repay/useRepayHealthMetrics.ts +++ b/composables/repay/useRepayHealthMetrics.ts @@ -1,7 +1,9 @@ import type { Ref, ComputedRef } from 'vue' import type { AccountBorrowPosition } from '~/entities/account' +import { getProjectedRates, getRoe } from '~/entities/vault' import { nanoToValue } from '~/utils/crypto-utils' -import { calculateRoe, computeNextLtv, computeNextHealth, computeLiquidationPrice } from '~/utils/repayUtils' +import { computeNextLtv, computeNextHealth, computeLiquidationPrice } from '~/utils/repayUtils' +import { createRaceGuard } from '~/utils/race-guard' interface UseRepayHealthMetricsOptions { position: Ref @@ -11,6 +13,7 @@ interface UseRepayHealthMetricsOptions { nextLiquidationLtv: ComputedRef collateralAmountAfter: ComputedRef collateralSupplyApy: ComputedRef + nextCollateralSupplyApy?: ComputedRef borrowApy: ComputedRef collateralValueUsd: Ref nextCollateralValueUsd: Ref @@ -27,6 +30,7 @@ export const useRepayHealthMetrics = (options: UseRepayHealthMetricsOptions) => nextLiquidationLtv, collateralAmountAfter, collateralSupplyApy, + nextCollateralSupplyApy, borrowApy, collateralValueUsd, nextCollateralValueUsd, @@ -69,11 +73,57 @@ export const useRepayHealthMetrics = (options: UseRepayHealthMetricsOptions) => const nextLiquidationPrice = computed(() => computeLiquidationPrice(priceRatio.value, nextHealth.value)) + const projectedBorrowApy = ref(null) + const projectedBorrowApyGuard = createRaceGuard() + + watchEffect(async () => { + const gen = projectedBorrowApyGuard.next() + const vault = borrowVault.value + const currentPosition = position.value + const repaid = debtRepaid.value + const currentBorrowApy = borrowApy.value + + if (!vault || !currentPosition || repaid === null) { + projectedBorrowApy.value = null + return + } + + try { + const repayAmount = repaid > currentPosition.borrowed + ? currentPosition.borrowed + : repaid + + const projected = await getProjectedRates( + vault.address, + vault.interestRateInfo.cash, + vault.interestRateInfo.borrows, + repayAmount, + -repayAmount, + ) + + if (projectedBorrowApyGuard.isStale(gen)) return + + if (!projected) { + projectedBorrowApy.value = null + return + } + + const currentRaw = nanoToValue(vault.interestRateInfo.borrowAPY || 0n, 25) + const projectedRaw = nanoToValue(projected.borrowAPY, 25) + projectedBorrowApy.value = (currentBorrowApy ?? 0) + (projectedRaw - currentRaw) + } + catch { + if (!projectedBorrowApyGuard.isStale(gen)) { + projectedBorrowApy.value = null + } + } + }) + const roeBefore = computed(() => - calculateRoe(collateralValueUsd.value, borrowValueUsd.value, collateralSupplyApy.value, borrowApy.value)) + getRoe(collateralValueUsd.value, collateralSupplyApy.value, borrowValueUsd.value, borrowApy.value)) const roeAfter = computed(() => - calculateRoe(nextCollateralValueUsd.value, nextBorrowValueUsd.value, collateralSupplyApy.value, borrowApy.value)) + getRoe(nextCollateralValueUsd.value, nextCollateralSupplyApy?.value ?? collateralSupplyApy.value, nextBorrowValueUsd.value, projectedBorrowApy.value ?? borrowApy.value)) return { currentHealth, diff --git a/composables/repay/useSavingsRepay.ts b/composables/repay/useSavingsRepay.ts index b5d50648..b26213af 100644 --- a/composables/repay/useSavingsRepay.ts +++ b/composables/repay/useSavingsRepay.ts @@ -6,7 +6,6 @@ import { useModal } from '~/components/ui/composables/useModal' import { OperationReviewModal } from '#components' import { useToast } from '~/components/ui/composables/useToast' import { getCashLimitedWithdrawAmount, isEVKVault, type Vault, type SecuritizeVault } from '~/entities/vault' -import { getAssetUsdValue } from '~/services/pricing/priceProvider' import type { AccountBorrowPosition } from '~/entities/account' import type { TxPlan } from '~/entities/txPlan' import { SwapperMode } from '~/entities/swap' @@ -65,6 +64,7 @@ export const useSavingsRepay = (options: UseSavingsRepayOptions) => { const { buildSwapPlan, buildSavingsRepayPlan, buildSavingsFullRepayPlan, buildSwapFullRepayPlan, executeTxPlan } = useEulerOperations() const { getVault: registryGetVault } = useVaultRegistry() const { finalizeTxAndRedirect } = useTxFinalization() + const { getCollateralApySnapshot } = usePositionCollateralApy() // --- Savings options --- const { savingsPositions, savingsVaults, savingsOptions, getSavingsPosition } = useRepaySavingsOptions() @@ -129,20 +129,24 @@ export const useSavingsRepay = (options: UseSavingsRepayOptions) => { return nanoToValue(position.value.liquidationLTV, 2) }) - // --- 4th USD watcher: primary collateral value (unchanged for savings) --- + // --- Collateral portfolio value/APY (unchanged for savings repay) --- const savingsCollateralUsdGuard = createRaceGuard() const savingsCollateralUsd = ref(null) + const savingsWeightedCollateralApy = ref(null) watchEffect(async () => { - if (!collateralVault.value || !position.value) { + if (!borrowVault.value || !position.value) { savingsCollateralUsd.value = null + savingsWeightedCollateralApy.value = null return } const gen = savingsCollateralUsdGuard.next() - const result = (await getAssetUsdValue(position.value.supplied || 0n, collateralVault.value, 'off-chain')) ?? null + const snapshot = await getCollateralApySnapshot(position.value, borrowVault.value) if (savingsCollateralUsdGuard.isStale(gen)) return - savingsCollateralUsd.value = result + savingsCollateralUsd.value = snapshot.supplyUsd + savingsWeightedCollateralApy.value = snapshot.weightedSupplyApy }) + const effectiveCollateralSupplyApy = computed(() => savingsWeightedCollateralApy.value ?? collateralSupplyApy.value) // --- Health metrics --- const health = useRepayHealthMetrics({ @@ -152,7 +156,7 @@ export const useSavingsRepay = (options: UseSavingsRepayOptions) => { priceRatio: oraclePriceRatio, nextLiquidationLtv, collateralAmountAfter, - collateralSupplyApy, + collateralSupplyApy: effectiveCollateralSupplyApy, borrowApy, collateralValueUsd: savingsCollateralUsd, nextCollateralValueUsd: savingsCollateralUsd, diff --git a/composables/repay/useWalletRepay.ts b/composables/repay/useWalletRepay.ts index a1807cc0..2055534c 100644 --- a/composables/repay/useWalletRepay.ts +++ b/composables/repay/useWalletRepay.ts @@ -8,7 +8,7 @@ import { getTotalCollateralValue } from '~/utils/position-estimates' import { useModal } from '~/components/ui/composables/useModal' import { OperationReviewModal } from '#components' import { useToast } from '~/components/ui/composables/useToast' -import { getNetAPY, getProjectedRates } from '~/entities/vault' +import { getNetAPYFromWeightedSupplySnapshot, getProjectedRates } from '~/entities/vault' import { getAssetUsdValueOrZero } from '~/services/pricing/priceProvider' import type { AccountBorrowPosition } from '~/entities/account' import type { TxPlan } from '~/entities/txPlan' @@ -63,6 +63,7 @@ export const useWalletRepay = (options: UseWalletRepayOptions) => { const { buildRepayPlan, buildFullRepayPlan, executeTxPlan } = useEulerOperations() const { isConnected } = useAccount() const { finalizeTxAndRedirect } = useTxFinalization() + const { getCollateralApySnapshot } = usePositionCollateralApy() const amount = ref('') const walletRepayPercent = ref(0) @@ -270,7 +271,7 @@ export const useWalletRepay = (options: UseWalletRepayOptions) => { const repayNano = valueToNano(amount.value, borrowVault.value.decimals) const remainingBorrow = (position.value.borrowed || 0n) - repayNano - const [projected, supplyUsd, borrowUsd] = await Promise.all([ + const [projected, collateralSnapshot, borrowUsd] = await Promise.all([ getProjectedRates( borrowVault.value.address, borrowVault.value.interestRateInfo.cash, @@ -278,7 +279,7 @@ export const useWalletRepay = (options: UseWalletRepayOptions) => { repayNano, -repayNano, ), - getAssetUsdValueOrZero(position.value.supplied || 0n, collateralVault.value, 'off-chain'), + getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(remainingBorrow > 0n ? remainingBorrow : 0n, borrowVault.value, 'off-chain'), ]) @@ -288,8 +289,8 @@ export const useWalletRepay = (options: UseWalletRepayOptions) => { ? borrowApy.value + (nanoToValue(projected.borrowAPY, 25) - nanoToValue(borrowVault.value.interestRateInfo.borrowAPY, 25)) : borrowApy.value - _estimateNetAPY.value = getNetAPY( - supplyUsd, + _estimateNetAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, collateralSupplyApy.value, borrowUsd, projectedBorrowApy, diff --git a/composables/repay/useWalletSwapRepay.ts b/composables/repay/useWalletSwapRepay.ts index 2826757b..b7c39393 100644 --- a/composables/repay/useWalletSwapRepay.ts +++ b/composables/repay/useWalletSwapRepay.ts @@ -8,7 +8,7 @@ import { getTotalCollateralValue } from '~/utils/position-estimates' import { useModal } from '~/components/ui/composables/useModal' import { OperationReviewModal } from '#components' import { useToast } from '~/components/ui/composables/useToast' -import { getNetAPY, getProjectedRates, type Vault, type VaultAsset } from '~/entities/vault' +import { getNetAPYFromWeightedSupplySnapshot, getProjectedRates, type Vault, type VaultAsset } from '~/entities/vault' import { getAssetUsdValue, getAssetUsdValueOrZero, getTokenUsdValue } from '~/services/pricing/priceProvider' import type { AccountBorrowPosition } from '~/entities/account' import type { TxPlan } from '~/entities/txPlan' @@ -72,6 +72,7 @@ export const useWalletSwapRepay = (options: UseWalletSwapRepayOptions) => { const { fetchSingleBalance } = useWallets() const { finalizeTxAndRedirect } = useTxFinalization() const { getVault: registryGetVault } = useVaultRegistry() + const { getCollateralApySnapshot } = usePositionCollateralApy() // --- State --- const selectedAsset = ref() @@ -457,7 +458,7 @@ export const useWalletSwapRepay = (options: UseWalletSwapRepayOptions) => { const currentDebt = getCurrentDebt() const nextBorrowed = currentDebt - debtRepaidNano - const [projected, supplyUsd, borrowUsd] = await Promise.all([ + const [projected, collateralSnapshot, borrowUsd] = await Promise.all([ getProjectedRates( borrowVault.value.address, borrowVault.value.interestRateInfo.cash, @@ -465,7 +466,7 @@ export const useWalletSwapRepay = (options: UseWalletSwapRepayOptions) => { debtRepaidNano, -debtRepaidNano, ), - getAssetUsdValueOrZero(position.value.supplied || 0n, collateralVault.value, 'off-chain'), + getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(nextBorrowed > 0n ? nextBorrowed : 0n, borrowVault.value, 'off-chain'), ]) if (estimatesGuard.isStale(gen)) return @@ -474,8 +475,8 @@ export const useWalletSwapRepay = (options: UseWalletSwapRepayOptions) => { ? borrowApy.value + (nanoToValue(projected.borrowAPY, 25) - nanoToValue(borrowVault.value.interestRateInfo.borrowAPY, 25)) : borrowApy.value - _estimateNetAPY.value = getNetAPY( - supplyUsd, + _estimateNetAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, collateralSupplyApy.value, borrowUsd, projectedBorrowApy, diff --git a/composables/usePositionCollateralApy.ts b/composables/usePositionCollateralApy.ts new file mode 100644 index 00000000..214c50be --- /dev/null +++ b/composables/usePositionCollateralApy.ts @@ -0,0 +1,179 @@ +import { getAddress, type Abi, type Address } from 'viem' +import type { AccountBorrowPosition } from '~/entities/account' +import { eulerAccountLensABI } from '~/entities/euler/abis' +import { + getProjectedRatesBatch, + type ProjectedRatesRequest, + type SecuritizeVault, + type Vault, +} from '~/entities/vault' +import { getCollateralUsdValueOrZero } from '~/services/pricing/priceProvider' +import { nanoToValue } from '~/utils/crypto-utils' + +type PositionCollateralVault = Vault | SecuritizeVault + +interface CollateralApyDelta { + vaultAddress: string + assetsDelta: bigint + projectRates?: boolean +} + +interface CollateralApySnapshotOptions { + deltas?: CollateralApyDelta[] +} + +interface CollateralApySnapshot { + supplyUsd: number + weightedSupplyApy: number | null +} + +interface CollateralApyEntry { + address: string + vault: PositionCollateralVault + assets: bigint + delta: bigint + projectRates: boolean | undefined +} + +export const usePositionCollateralApy = () => { + const { getSupplyRewardApy } = useRewardsApy() + const { withIntrinsicSupplyApy } = useIntrinsicApy() + const { getOrFetch } = useVaultRegistry() + const { isReady: isVaultsReady } = useVaults() + const { eulerLensAddresses, isReady: isEulerAddressesReady, loadEulerConfig } = useEulerAddresses() + const { client: rpcClient } = useRpcClient() + + const normalize = (address?: string | null) => { + if (!address) return '' + try { + return getAddress(address) + } + catch { + return '' + } + } + + const getCollateralAssets = async ( + position: AccountBorrowPosition, + vaultAddress: string, + primaryAddress: string, + ) => { + if (normalize(vaultAddress) === normalize(primaryAddress)) { + return position.supplied || 0n + } + + const lensAddress = eulerLensAddresses.value?.accountLens + if (!lensAddress || !rpcClient.value) { + return 0n + } + + try { + const res = await rpcClient.value.readContract({ + address: lensAddress as Address, + abi: eulerAccountLensABI as Abi, + functionName: 'getAccountInfo', + args: [position.subAccount, vaultAddress], + }) as Record> + return res.vaultAccountInfo.assets as bigint + } + catch { + return 0n + } + } + + const getCollateralApySnapshot = async ( + position: AccountBorrowPosition | null | undefined, + liabilityVault: Vault | undefined, + options: CollateralApySnapshotOptions = {}, + ): Promise => { + if (!position || !liabilityVault) { + return { supplyUsd: 0, weightedSupplyApy: null } + } + + try { + if (!isEulerAddressesReady.value) { + await loadEulerConfig() + } + await until(isVaultsReady).toBe(true) + + const primaryAddress = normalize(position.collateral.address) + const deltaByAddress = new Map( + (options.deltas || []) + .map(delta => [normalize(delta.vaultAddress), delta]) + .filter(([address]) => Boolean(address)) as Array<[string, CollateralApyDelta]>, + ) + const collateralAddresses = position.collaterals?.length + ? position.collaterals + : [position.collateral.address] + const normalized = collateralAddresses.map(normalize).filter(Boolean) + const allAddresses = Array.from(new Set([ + primaryAddress, + ...normalized, + ...deltaByAddress.keys(), + ].filter(Boolean))) + + const entries = (await Promise.all(allAddresses.map(async (address): Promise => { + const vault = await getOrFetch(address) as PositionCollateralVault | undefined + if (!vault) return null + const currentAssets = await getCollateralAssets(position, address, primaryAddress) + const delta = deltaByAddress.get(address)?.assetsDelta || 0n + const nextAssets = currentAssets + delta + return { + address, + vault, + assets: nextAssets > 0n ? nextAssets : 0n, + delta, + projectRates: deltaByAddress.get(address)?.projectRates, + } + }))).filter((entry): entry is CollateralApyEntry => entry !== null) + + const projectionRequests = entries.reduce>((acc, entry, index) => { + if (!entry.projectRates || entry.delta === 0n) return acc + acc.push({ + index, + request: { + vaultAddress: entry.vault.address, + currentCash: entry.vault.interestRateInfo.cash, + currentBorrows: entry.vault.interestRateInfo.borrows, + cashDelta: entry.delta, + borrowsDelta: 0n, + }, + }) + return acc + }, []) + const projectedRates = projectionRequests.length + ? await getProjectedRatesBatch(projectionRequests.map(item => item.request)) + : [] + const projectedByIndex = new Map(projectionRequests.map((item, index) => [item.index, projectedRates[index]])) + + const valued = await Promise.all(entries.map(async (entry, index) => { + const supplyUsd = await getCollateralUsdValueOrZero(entry.assets, liabilityVault, entry.vault as Vault, 'off-chain') + const currentRaw = nanoToValue(entry.vault.interestRateInfo.supplyAPY || 0n, 25) + const baseApy = withIntrinsicSupplyApy(currentRaw, entry.vault.asset.address) + getSupplyRewardApy(entry.vault.address) + const projected = projectedByIndex.get(index) + const supplyApy = projected + ? baseApy + (nanoToValue(projected.supplyAPY, 25) - currentRaw) + : baseApy + + return { supplyUsd, supplyApy } + })) + + const supplyUsd = valued.reduce((sum, item) => sum + item.supplyUsd, 0) + if (!Number.isFinite(supplyUsd) || supplyUsd <= 0) { + return { supplyUsd: 0, weightedSupplyApy: null } + } + + return { + supplyUsd, + weightedSupplyApy: valued.reduce((sum, item) => sum + item.supplyUsd * item.supplyApy, 0) / supplyUsd, + } + } + catch { + return { supplyUsd: 0, weightedSupplyApy: null } + } + } + + return { + getCollateralApySnapshot, + } +} diff --git a/composables/useSwapPageLogic.ts b/composables/useSwapPageLogic.ts index d336660a..4eb05906 100644 --- a/composables/useSwapPageLogic.ts +++ b/composables/useSwapPageLogic.ts @@ -339,7 +339,7 @@ export const useSwapPageLogic = (options: UseSwapPageLogicOptions) => { if (!isConnected.value) return false if (!fromVault.value?.asset || !toVault.value?.asset) return true if (isSameAsset.value) { - return isLoading.value || !(+fromAmount.value) || !!errorText.value || isSameVault.value + return isLoading.value || !(+fromAmount.value) || !!errorText.value || isSameVault.value || additionalErrors.some(err => !!err.value) } if (!selectedQuote.value) return true const amountOut = getQuoteAmount(selectedQuote.value, 'amountOut') diff --git a/entities/vault/apy.ts b/entities/vault/apy.ts index fac5ca68..0212b2c2 100644 --- a/entities/vault/apy.ts +++ b/entities/vault/apy.ts @@ -4,6 +4,7 @@ import { SECONDS_IN_YEAR, TARGET_TIME_AGO } from '~/entities/constants' import { eulerUtilsLensABI, eulerVaultLensABI } from '~/entities/euler/abis' import { vaultConvertToAssetsAbi } from '~/abis/vault' import { getPublicClient } from '~/utils/public-client' +import { batchLensCalls } from '~/utils/multicall' import { logConciseFetchError } from './log-fetch-error' export interface ProjectedRates { @@ -11,36 +12,24 @@ export interface ProjectedRates { borrowAPY: bigint // 27 decimals } -export const getProjectedRates = async ( - vaultAddress: string, - currentCash: bigint, - currentBorrows: bigint, - cashDelta: bigint, - borrowsDelta: bigint, -): Promise => { - const { client: rpcClient } = useRpcClient() - const { eulerLensAddresses } = useEulerAddresses() - - if (!eulerLensAddresses.value?.vaultLens) { - return null - } - - const adjustedCash = currentCash + cashDelta < 0n ? 0n : currentCash + cashDelta - const adjustedBorrows = currentBorrows + borrowsDelta < 0n ? 0n : currentBorrows + borrowsDelta +export interface ProjectedRatesRequest { + vaultAddress: string + currentCash: bigint + currentBorrows: bigint + cashDelta: bigint + borrowsDelta: bigint +} - if (adjustedCash === 0n && adjustedBorrows === 0n) { - return { supplyAPY: 0n, borrowAPY: 0n } - } +const toAdjustedRateState = (request: ProjectedRatesRequest) => { + const adjustedCash = request.currentCash + request.cashDelta < 0n ? 0n : request.currentCash + request.cashDelta + const adjustedBorrows = request.currentBorrows + request.borrowsDelta < 0n ? 0n : request.currentBorrows + request.borrowsDelta - const result = await rpcClient.value!.readContract({ - address: eulerLensAddresses.value.vaultLens as Address, - abi: eulerVaultLensABI, - functionName: 'getVaultInterestRateModelInfo', - args: [vaultAddress as Address, [adjustedCash], [adjustedBorrows]], - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return - }) as Record + return { adjustedCash, adjustedBorrows } +} - if (result.queryFailure || !result.interestRateInfo?.length) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return +const parseProjectedRatesResult = (result: Record | null): ProjectedRates | null => { + if (!result || result.queryFailure || !result.interestRateInfo?.length) { return null } @@ -51,6 +40,102 @@ export const getProjectedRates = async ( } } +export const getProjectedRatesBatch = async ( + requests: ProjectedRatesRequest[], +): Promise> => { + const { client: rpcClient, rpcUrl } = useRpcClient() + const { eulerLensAddresses, eulerCoreAddresses } = useEulerAddresses() + + if (!requests.length) { + return [] + } + + if (!eulerLensAddresses.value?.vaultLens) { + return requests.map(() => null) + } + + const prepared = requests.map((request) => { + const { adjustedCash, adjustedBorrows } = toAdjustedRateState(request) + return { + request, + adjustedCash, + adjustedBorrows, + isEmpty: adjustedCash === 0n && adjustedBorrows === 0n, + } + }) + + const results: Array = prepared.map(item => + item.isEmpty ? { supplyAPY: 0n, borrowAPY: 0n } : null, + ) + const active = prepared + .map((item, index) => ({ ...item, index })) + .filter(item => !item.isEmpty) + + if (!active.length) { + return results + } + + const calls = active.map(item => ({ + functionName: 'getVaultInterestRateModelInfo', + args: [ + item.request.vaultAddress as Address, + [item.adjustedCash], + [item.adjustedBorrows], + ], + })) + + if (eulerCoreAddresses.value?.evc && rpcUrl.value) { + const batchResults = await batchLensCalls>( + eulerCoreAddresses.value.evc, + eulerLensAddresses.value.vaultLens, + eulerVaultLensABI, + calls, + rpcUrl.value, + ) + + active.forEach((item, activeIndex) => { + if (batchResults[activeIndex]?.success) { + results[item.index] = parseProjectedRatesResult(batchResults[activeIndex].result as Record | null) + } + }) + + return results + } + + const fallbackResults = await Promise.all(calls.map(async call => + rpcClient.value!.readContract({ + address: eulerLensAddresses.value!.vaultLens as Address, + abi: eulerVaultLensABI, + functionName: 'getVaultInterestRateModelInfo', + args: call.args as [Address, bigint[], bigint[]], + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic lens contract return + }) as Promise>, + )) + + active.forEach((item, activeIndex) => { + results[item.index] = parseProjectedRatesResult(fallbackResults[activeIndex]) + }) + + return results +} + +export const getProjectedRates = async ( + vaultAddress: string, + currentCash: bigint, + currentBorrows: bigint, + cashDelta: bigint, + borrowsDelta: bigint, +): Promise => { + const [result] = await getProjectedRatesBatch([{ + vaultAddress, + currentCash, + currentBorrows, + cashDelta, + borrowsDelta, + }]) + return result +} + export const computeAPYs = (borrowSPY: bigint, cash: bigint, borrows: bigint, interestFee: bigint) => { const { client: rpcClient } = useRpcClient() const { eulerLensAddresses } = useEulerAddresses() @@ -85,7 +170,31 @@ export const getNetAPY = ( + equity * (loopingRewardAPY || 0) return sum / supplyUSD } -export const getRoe = ( + +interface WeightedSupplySnapshot { + supplyUsd: number + weightedSupplyApy: number | null +} + +export const getNetAPYFromWeightedSupplySnapshot = ( + snapshot: WeightedSupplySnapshot, + fallbackSupplyAPY: number, + borrowUSD: number, + borrowAPY: number, + fallbackSupplyRewardAPY?: number | null, + borrowRewardAPY?: number | null, + loopingRewardAPY?: number | null, +) => getNetAPY( + snapshot.supplyUsd, + snapshot.weightedSupplyApy ?? fallbackSupplyAPY, + borrowUSD, + borrowAPY, + snapshot.weightedSupplyApy === null ? fallbackSupplyRewardAPY : null, + borrowRewardAPY, + loopingRewardAPY, +) + +export function getRoe( supplyUSD: number, supplyAPY: number, borrowUSD: number, @@ -93,13 +202,38 @@ export const getRoe = ( supplyRewardAPY?: number | null, borrowRewardAPY?: number | null, loopingRewardAPY?: number | null, -) => { +): number +export function getRoe( + supplyUSD: number | null, + supplyAPY: number | null, + borrowUSD: number | null, + borrowAPY: number | null, + supplyRewardAPY?: number | null, + borrowRewardAPY?: number | null, + loopingRewardAPY?: number | null, +): number | null +export function getRoe( + supplyUSD: number | null, + supplyAPY: number | null, + borrowUSD: number | null, + borrowAPY: number | null, + supplyRewardAPY?: number | null, + borrowRewardAPY?: number | null, + loopingRewardAPY?: number | null, +) { + if (supplyUSD === null || borrowUSD === null || supplyAPY === null || borrowAPY === null) { + return null + } const equity = supplyUSD - borrowUSD + if (!Number.isFinite(equity)) return null if (equity <= 0) return 0 const netYield = supplyUSD * (supplyAPY + (supplyRewardAPY || 0)) - borrowUSD * (borrowAPY - (borrowRewardAPY || 0)) + equity * (loopingRewardAPY || 0) + if (!Number.isFinite(netYield)) { + return null + } return netYield / equity } diff --git a/entities/vault/index.ts b/entities/vault/index.ts index c76e2bd7..b79cb935 100644 --- a/entities/vault/index.ts +++ b/entities/vault/index.ts @@ -79,10 +79,12 @@ export type { export { computeAPYs, getProjectedRates, + getProjectedRatesBatch, getNetAPY, + getNetAPYFromWeightedSupplySnapshot, getRoe, } from './apy' -export type { ProjectedRates } from './apy' +export type { ProjectedRates, ProjectedRatesRequest } from './apy' // Utility functions export { diff --git a/pages/position/[number]/borrow/index.vue b/pages/position/[number]/borrow/index.vue index e14c186b..a013afe2 100644 --- a/pages/position/[number]/borrow/index.vue +++ b/pages/position/[number]/borrow/index.vue @@ -4,7 +4,7 @@ import { FixedPoint } from '~/utils/fixed-point' import { useModal } from '~/components/ui/composables/useModal' import { OperationReviewModal } from '#components' import { useToast } from '~/components/ui/composables/useToast' -import { type BorrowVaultPair, getNetAPY, getProjectedRates, type VaultAsset } from '~/entities/vault' +import { type BorrowVaultPair, getNetAPYFromWeightedSupplySnapshot, getProjectedRates, type VaultAsset } from '~/entities/vault' import { getHookDisabledWarning, getUtilisationWarning, getBorrowCapWarning } from '~/composables/useVaultWarnings' import { isOpDisabled, OP_BORROW } from '~/utils/vault-hooks' import { getAssetUsdValueOrZero, getAssetOraclePrice, getCollateralOraclePrice, conservativePriceRatio } from '~/services/pricing/priceProvider' @@ -34,6 +34,7 @@ const { fetchSingleBalance } = useWallets() const { runSimulation, simulationError, clearSimulationError } = useTxPlanSimulation() const { getSupplyRewardApy, getBorrowRewardApy } = useRewardsApy() const { withIntrinsicBorrowApy, withIntrinsicSupplyApy } = useIntrinsicApy() +const { getCollateralApySnapshot } = usePositionCollateralApy() const priceInvert = usePriceInvert( () => collateralVault.value?.asset.symbol, @@ -189,12 +190,12 @@ const load = async () => { ? Infinity : (Number(pair.value?.liquidationLTV || 0n) / 100) / currentLtvFloat currentLiquidationPrice.value = currentHealth.value < 0.1 ? Infinity : priceFixed.value.toUnsafeFloat() / currentHealth.value - const [collUsd, borUsd] = await Promise.all([ - getAssetUsdValueOrZero(position.value!.supplied || 0, collateralVault.value!, 'off-chain'), + const [collateralSnapshot, borUsd] = await Promise.all([ + getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(position.value!.borrowed || 0, borrowVault.value!, 'off-chain'), ]) - currentNetAPY.value = getNetAPY( - collUsd, + currentNetAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, collateralSupplyApy.value, borUsd, borrowApy.value, @@ -375,7 +376,7 @@ const updateAsyncEstimates = useDebounceFn(async () => { const existingBorrow = nanoToValue(position.value?.borrowed || 0n, borrowVault.value.decimals) const totalBorrow = existingBorrow + (+borrowAmount.value || 0) - const [borrowProjected, collateralUsd, borrowUsd] = await Promise.all([ + const [borrowProjected, collateralSnapshot, borrowUsd] = await Promise.all([ getProjectedRates( borrowVault.value.address, borrowVault.value.interestRateInfo.cash, @@ -383,7 +384,7 @@ const updateAsyncEstimates = useDebounceFn(async () => { -additionalBorrowNano, additionalBorrowNano, ), - getAssetUsdValueOrZero(+collateralAmount.value || 0, collateralVault.value!, 'off-chain'), + getCollateralApySnapshot(position.value, borrowVault.value), getAssetUsdValueOrZero(totalBorrow, borrowVault.value!, 'off-chain'), ]) @@ -393,8 +394,8 @@ const updateAsyncEstimates = useDebounceFn(async () => { ? borrowApy.value + (nanoToValue(borrowProjected.borrowAPY, 25) - nanoToValue(borrowVault.value.interestRateInfo.borrowAPY, 25)) : borrowApy.value - netAPY.value = getNetAPY( - collateralUsd, + netAPY.value = getNetAPYFromWeightedSupplySnapshot( + collateralSnapshot, collateralSupplyApy.value, borrowUsd, projectedBorrowApy, diff --git a/pages/position/[number]/borrow/swap.vue b/pages/position/[number]/borrow/swap.vue index 94d17c43..ae766cf0 100644 --- a/pages/position/[number]/borrow/swap.vue +++ b/pages/position/[number]/borrow/swap.vue @@ -2,17 +2,18 @@ import { useAccount } from '@wagmi/vue' import { formatUnits, zeroAddress, type Address } from 'viem' import type { AccountBorrowPosition } from '~/entities/account' -import type { Vault, VaultAsset } from '~/entities/vault' +import { getProjectedRates, getRoe, type Vault, type VaultAsset } from '~/entities/vault' import { getAssetUsdValue, getAssetOraclePrice, getCollateralOraclePrice, conservativePriceRatioNumber } from '~/services/pricing/priceProvider' import { useSwapDebtOptions } from '~/composables/useSwapDebtOptions' import { SwapperMode } from '~/entities/swap' import type { TxPlan } from '~/entities/txPlan' import { useIntrinsicApy } from '~/composables/useIntrinsicApy' import { formatNumber, formatSmartAmount, formatHealthScore } from '~/utils/string-utils' -import { formatLiquidationBuffer as formatLiqBuffer, calculateRoe } from '~/utils/repayUtils' +import { formatLiquidationBuffer as formatLiqBuffer } from '~/utils/repayUtils' import { nanoToValue } from '~/utils/crypto-utils' import { useSwapPageLogic } from '~/composables/useSwapPageLogic' import type { DisabledReasonInfo } from '~/components/entities/vault/form/types' +import { createRaceGuard } from '~/utils/race-guard' const route = useRoute() const { isConnected, address } = useAccount() @@ -88,6 +89,7 @@ const toBorrowApy = computed(() => { const base = nanoToValue(toVault.value.interestRateInfo.borrowAPY || 0n, 25) return withIntrinsicBorrowApy(base, toVault.value.asset.address) - getBorrowRewardApy(toVault.value.address, collateralVault.value?.address) }) +const nextBorrowApy = ref(null) const supplyValueUsd = ref(null) watchEffect(async () => { @@ -106,9 +108,10 @@ watchEffect(async () => { currentBorrowValueUsd.value = (await getAssetUsdValue(position.value.borrowed, fromVault.value, 'off-chain')) ?? null }) const nextBorrowValueUsd = ref(null) +const nextBorrowGuard = createRaceGuard() -const roeBefore = computed(() => calculateRoe(supplyValueUsd.value, currentBorrowValueUsd.value, collateralSupplyApy.value, fromBorrowApy.value)) -const roeAfter = computed(() => calculateRoe(supplyValueUsd.value, nextBorrowValueUsd.value, collateralSupplyApy.value, toBorrowApy.value)) +const roeBefore = computed(() => getRoe(supplyValueUsd.value, collateralSupplyApy.value, currentBorrowValueUsd.value, fromBorrowApy.value)) +const roeAfter = computed(() => getRoe(supplyValueUsd.value, collateralSupplyApy.value, nextBorrowValueUsd.value, nextBorrowApy.value)) // ── Health metrics ─────────────────────────────────────────────────────── const priceRatio = computed(() => { @@ -122,7 +125,11 @@ const collateralAmount = computed(() => { return nanoToValue(position.value.supplied, collateralVault.value.decimals) }) const nextBorrowAmount = computed(() => { - if (!quote.value || !toVault.value) return null + if (!toVault.value) return null + if (isSameAsset.value) { + return currentDebt.value > 0n ? nanoToValue(currentDebt.value, toVault.value.decimals) : null + } + if (!quote.value) return null return nanoToValue(BigInt(quote.value.amountIn), toVault.value.decimals) }) @@ -164,7 +171,7 @@ const nextLiquidationPrice = computed(() => { }) const healthError = computed(() => { - if (!quote.value || nextHealth.value === null) return null + if ((!quote.value && !isSameAsset.value) || nextHealth.value === null) return null if (!Number.isFinite(nextHealth.value)) return null return nextHealth.value <= 1 ? 'Swap would make position unhealthy' : null }) @@ -189,6 +196,8 @@ const swap = useSwapPageLogic({ buildQuoteRequest(amount) { if (!fromVault.value || !toVault.value || !position.value) return null if (amount > currentDebt.value) return null + const refinanceAmount = currentDebt.value + if (refinanceAmount <= 0n) return null const accountIn = (address.value || zeroAddress) as Address const accountOut = (position.value.subAccount || accountIn) as Address return { @@ -197,7 +206,7 @@ const swap = useSwapPageLogic({ tokenOut: fromVault.value.asset.address as Address, accountIn, accountOut, - amount, + amount: refinanceAmount, vaultIn: toVault.value.address as Address, receiver: fromVault.value.address as Address, slippage: slippage.value, @@ -212,11 +221,10 @@ const swap = useSwapPageLogic({ async buildPlan(): Promise { if (!fromVault.value || !toVault.value) throw new Error('Vaults not loaded') if (isSameAsset.value) { - const amount = valueToNano(fromAmount.value, fromVault.value.asset.decimals) return buildSameAssetDebtSwapPlan({ oldVaultAddress: fromVault.value.address, newVaultAddress: toVault.value.address, - amount, + amount: currentDebt.value, subAccount: position.value?.subAccount || address.value!, enabledCollaterals: position.value?.collaterals, }) @@ -273,11 +281,85 @@ const disabledReasonInfo = computed((): DisabledReasonInfo | undefined => { // Must be after `swap` destructuring so `quote` is in scope watchEffect(async () => { - if (!quote.value || !toVault.value) { + const gen = nextBorrowGuard.next() + + if ((!quote.value && !isSameAsset.value) || !toVault.value || !fromVault.value) { nextBorrowValueUsd.value = null + nextBorrowApy.value = null return } - nextBorrowValueUsd.value = (await getAssetUsdValue(BigInt(quote.value.amountIn), toVault.value, 'off-chain')) ?? null + + try { + const swappedDebt = isSameAsset.value + ? currentDebt.value + : currentDebt.value + const repaidDebt = swappedDebt > currentDebt.value ? currentDebt.value : swappedDebt + const newBorrowAmount = isSameAsset.value + ? currentDebt.value + : BigInt(quote.value!.amountIn) + const remainingDebt = currentDebt.value - repaidDebt + + if (newBorrowAmount <= 0n) { + nextBorrowValueUsd.value = null + nextBorrowApy.value = null + return + } + + const [remainingBorrowUsd, newBorrowUsd, projectedFromBorrow, projectedToBorrow] = await Promise.all([ + remainingDebt > 0n + ? getAssetUsdValue(remainingDebt, fromVault.value, 'off-chain') + : Promise.resolve(0), + getAssetUsdValue(newBorrowAmount, toVault.value, 'off-chain'), + remainingDebt > 0n + ? getProjectedRates( + fromVault.value.address, + fromVault.value.interestRateInfo.cash, + fromVault.value.interestRateInfo.borrows, + repaidDebt, + -repaidDebt, + ) + : Promise.resolve(null), + getProjectedRates( + toVault.value.address, + toVault.value.interestRateInfo.cash, + toVault.value.interestRateInfo.borrows, + -newBorrowAmount, + newBorrowAmount, + ), + ]) + if (nextBorrowGuard.isStale(gen)) return + + const oldBorrowUsd = remainingBorrowUsd ?? 0 + const targetBorrowUsd = newBorrowUsd ?? 0 + const totalBorrowUsd = oldBorrowUsd + targetBorrowUsd + nextBorrowValueUsd.value = totalBorrowUsd > 0 ? totalBorrowUsd : null + + const oldBorrowApy = projectedFromBorrow + ? (fromBorrowApy.value ?? 0) + (nanoToValue(projectedFromBorrow.borrowAPY, 25) - nanoToValue(fromVault.value.interestRateInfo.borrowAPY, 25)) + : fromBorrowApy.value + const targetBorrowApy = projectedToBorrow + ? (toBorrowApy.value ?? 0) + (nanoToValue(projectedToBorrow.borrowAPY, 25) - nanoToValue(toVault.value.interestRateInfo.borrowAPY, 25)) + : toBorrowApy.value + + if (totalBorrowUsd > 0 && oldBorrowApy !== null && targetBorrowApy !== null) { + nextBorrowApy.value = ( + oldBorrowUsd * oldBorrowApy + + targetBorrowUsd * targetBorrowApy + ) / totalBorrowUsd + } + else if (totalBorrowUsd > 0 && targetBorrowApy !== null) { + nextBorrowApy.value = targetBorrowApy + } + else { + nextBorrowApy.value = null + } + } + catch { + if (!nextBorrowGuard.isStale(gen)) { + nextBorrowValueUsd.value = null + nextBorrowApy.value = null + } + } }) // ── Position loading ───────────────────────────────────────────────────── @@ -481,7 +563,7 @@ const onToVaultChange = (selectedIndex: number) => { @@ -503,7 +585,7 @@ const onToVaultChange = (selectedIndex: number) => { >

-