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
7 changes: 6 additions & 1 deletion components/entities/asset/AssetInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const props = withDefaults(defineProps<{
assetSelectorPlaceholder?: string
assetSelectorSelected?: boolean
selectedSource?: string // Matches CollateralOption.type (e.g. 'wallet' / 'saving' / 'vault') for the source-chip indicator
selectedOptionId?: string // Stable internal identity for otherwise identical modal rows
selectedSubAccount?: string // Disambiguates between multiple savings positions on different sub-accounts
selectedVaultAddress?: string // Disambiguates same sub-account positions across different vaults (e.g. wallet rows)
maxHandler?: () => void // When provided, replaces the default "Max" button behavior
Expand Down Expand Up @@ -81,6 +82,10 @@ const matchesSelectedVault = (a?: string, b?: string) => {
return a.toLowerCase() === b.toLowerCase()
}
const getSelectedIdx = () => {
if (props.selectedOptionId && props.collateralOptions?.length) {
const exact = props.collateralOptions.findIndex(option => option.selectionId === props.selectedOptionId)
if (exact >= 0) return exact
}
if (props.selectedSource && props.collateralOptions?.length) {
// Prefer the option that matches BOTH type and the optional disambiguators
// (sub-account for savings rows, vault address for wallet rows). Without
Expand All @@ -99,7 +104,7 @@ const getSelectedIdx = () => {
}
const selectedIdx = ref(getSelectedIdx())
watch(
[() => props.selectedSource, () => props.selectedSubAccount, () => props.selectedVaultAddress, () => props.collateralOptions],
[() => props.selectedOptionId, () => props.selectedSource, () => props.selectedSubAccount, () => props.selectedVaultAddress, () => props.collateralOptions],
() => { selectedIdx.value = getSelectedIdx() },
)
const friendlyBalance = computed(() => nanoToValue(props.balance ?? 0n, props.asset?.decimals || 18))
Expand Down
180 changes: 145 additions & 35 deletions composables/repay/useCollateralSwapRepay.ts

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions composables/useCrossPositionRepayCollateralOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { EVault, PortfolioBorrowPosition, PortfolioSavingsPosition, VaultEntity } from '@eulerxyz/euler-v2-sdk'
import { getAddress, type Address } from 'viem'
import type { CollateralOption } from '~/types/collateral-option'
import { buildCollateralOption, computeSupplyApy } from '~/utils/collateralOptions'
import { useReactiveMap } from '~/composables/useReactiveMap'

export interface CrossPositionRepayCollateralItem {
id: string
vault: EVault
option: CollateralOption
sourceAccount: Address
assets: bigint
shares: bigint
}

export const buildCrossPositionRepayCollateralCandidates = ({
positions,
savingsPositions = [],
targetPosition,
liabilityVault,
enabled,
}: {
positions: readonly PortfolioBorrowPosition<VaultEntity>[]
savingsPositions?: readonly PortfolioSavingsPosition<VaultEntity>[]
targetPosition?: PortfolioBorrowPosition<VaultEntity>
liabilityVault?: EVault
enabled: boolean
}) => {
if (!enabled || !targetPosition || !liabilityVault) return []

const targetAccount = getAddress(targetPosition.subAccount)
const liabilityVaultAddress = getAddress(liabilityVault.address)
const candidates = new Map<string, {
id: string
vault: EVault
sourceAccount: Address
assets: bigint
shares: bigint
}>()

const addCandidate = (sourceAccountValue: Address, assets: bigint, shares: bigint) => {
const sourceAccount = getAddress(sourceAccountValue) as Address
if (sourceAccount === targetAccount) return

if (assets <= 0n || shares <= 0n) return

const id = `${sourceAccount.toLowerCase()}:${liabilityVaultAddress.toLowerCase()}`
candidates.set(id, {
id,
vault: liabilityVault,
sourceAccount,
assets,
shares,
})
}

for (const position of positions) {
const collateral = position.collaterals.find(candidate =>
getAddress(candidate.vaultAddress) === liabilityVaultAddress,
)
if (!collateral) continue
addCandidate(position.subAccount as Address, collateral.assets, collateral.shares)
}

// After the first half of a reciprocal batch, the repaid borrow position is
// projected as savings. Its collateral flag remains enabled because cleanup
// is deferred, so keep that exact-vault deposit available for the second leg.
for (const position of savingsPositions) {
if (!position.position.isCollateral) continue
if (getAddress(position.position.vaultAddress) !== liabilityVaultAddress) continue
addCandidate(position.subAccount as Address, position.assets, position.shares)
}

return [...candidates.values()]
}

export const useCrossPositionRepayCollateralOptions = ({
targetPosition,
liabilityVault,
}: {
targetPosition: Ref<PortfolioBorrowPosition<VaultEntity> | undefined>
liabilityVault: Ref<EVault | undefined>
}) => {
const { borrowPositions, depositPositions } = useEulerAccount()
const { settings } = useUserSettings()
const { viewer } = useApyVisibility()
const enableIntrinsicApy = computed(() => settings.value.enableIntrinsicApy)
const enableRewardsApy = computed(() => settings.value.enableRewardsApy)

const candidates = computed(() => buildCrossPositionRepayCollateralCandidates({
positions: borrowPositions.value,
savingsPositions: depositPositions.value,
targetPosition: targetPosition.value,
liabilityVault: liabilityVault.value,
enabled: settings.value.enableAdvancedMode,
}))

const items = useReactiveMap(
candidates,
[viewer, enableIntrinsicApy, enableRewardsApy],
async candidate => ({
...candidate,
option: {
...await buildCollateralOption({
vault: candidate.vault,
type: 'vault',
amount: nanoToValue(candidate.assets, candidate.vault.asset.decimals),
priceAmount: nanoToValue(candidate.assets, candidate.vault.asset.decimals),
apy: computeSupplyApy(candidate.vault, viewer.value, {
enableIntrinsicApy: enableIntrinsicApy.value,
enableRewardsApy: enableRewardsApy.value,
}),
tagContext: 'supply-source',
subAccount: candidate.sourceAccount,
}),
selectionId: candidate.id,
},
} satisfies CrossPositionRepayCollateralItem),
)

return { items }
}
9 changes: 7 additions & 2 deletions pages/position/[number]/repay.vue
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ const addToBatchWithoutWarnings = async () => {
if (formTab.value === 'collateral') {
const quote = collateral.isSameAsset.value ? undefined : collateral.quotes.selectedQuote.value ?? undefined
const sourceVault = collateral.sourceVault.value
const sourceAccount = collateral.selectedSourceAccount.value
const sourceAmount = collateral.amount.value
const sourceDebtAmount = collateral.debtAmount.value
const sourceDirection = collateral.direction.value
Expand All @@ -306,18 +307,21 @@ const addToBatchWithoutWarnings = async () => {
label: `Repay from ${srcSymbol} collateral → ${borrowSymbol}`,
intent: quoteIntents?.[0] ?? collateral.createRepayIntent(quote, {
sourceVault,
sourceAccount,
amount: sourceAmount,
debtAmount: sourceDebtAmount,
direction: sourceDirection,
isSameAsset,
}),
subAccount: position.value.subAccount as Address,
affectedSubAccounts: getFullRepayAffectedSubAccounts(isClosing),
affectedSubAccounts: collateral.isCrossPositionSource.value
? getAffectedSubAccounts(position.value.subAccount, sourceAccount)
: getFullRepayAffectedSubAccounts(isClosing),
review: { type: 'repay', asset: sourceVault.asset, amount: sourceAmount, swapToAsset: borrowVault.value.asset, quoteFetchedAt: isSameAsset ? null : collateral.quotes.effectiveQuoteFetchedAt.value },
})
collateral.amount.value = ''
collateral.debtAmount.value = ''
redirectAfterRepayAdd(isClosing)
redirectAfterRepayAdd(isClosing && !collateral.isCrossPositionSource.value)
return
}

Expand Down Expand Up @@ -910,6 +914,7 @@ watch(formTab, () => {
:asset="collateral.sourceVault.value.asset"
:vault="collateral.sourceVault.value"
:collateral-options="collateral.repayCollateralOptions.value"
:selected-option-id="collateral.selectedSourceId.value"
:balance="collateral.sourceBalance.value"
:max-handler="collateral.onSourceMax"
maxable
Expand Down
121 changes: 116 additions & 5 deletions tests/composables/useCollateralSwapRepay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { SwapperMode, type Account, type EVault, type IHasVaultAddress, type Por
import type { Address } from 'viem'
import { useCollateralSwapRepay } from '~/composables/repay/useCollateralSwapRepay'

const { USER, SOURCE_VAULT, sourceVault, borrowVault, position, planAccount, mocks } = vi.hoisted(() => {
const { USER, SOURCE_ACCOUNT, SOURCE_VAULT, sourceVault, borrowVault, position, sourcePosition, planAccount, mocks } = vi.hoisted(() => {
const USER = '0x0000000000000000000000000000000000000001' as Address
const SOURCE_ACCOUNT = '0x0000000000000000000000000000000000000006' as Address
const SOURCE_VAULT = '0x0000000000000000000000000000000000000002' as Address
const SOURCE_ASSET = '0x0000000000000000000000000000000000000003' as Address
const BORROW_VAULT = '0x0000000000000000000000000000000000000004' as Address
Expand Down Expand Up @@ -55,15 +56,33 @@ const { USER, SOURCE_VAULT, sourceVault, borrowVault, position, planAccount, moc
}],
} as unknown as PortfolioBorrowPosition<VaultEntity>

const sourcePosition = {
subAccount: SOURCE_ACCOUNT,
borrowed: 1_000n,
supplied: 2_500n,
collateralVault: borrowVault,
collateralVaults: [BORROW_VAULT],
collaterals: [{
vaultAddress: BORROW_VAULT,
assets: 2_500n,
shares: 2_500n,
}],
} as unknown as PortfolioBorrowPosition<VaultEntity>

return {
USER,
SOURCE_ACCOUNT,
SOURCE_VAULT,
sourceVault,
borrowVault,
position,
sourcePosition,
planAccount: { chainId: 1 } as Account<IHasVaultAddress>,
mocks: {
getCollateralApySnapshot: vi.fn(),
createIntent: vi.fn(),
planRepayFromSource: vi.fn(),
crossPositionItems: [] as Array<Record<string, unknown>>,
quoteInstances: [] as Array<{
amountField: 'amountIn' | 'amountOut'
selectedQuote: { value: SwapQuote | null }
Expand Down Expand Up @@ -104,6 +123,12 @@ vi.mock('~/composables/useSwapCollateralOptions', () => ({
}),
}))

vi.mock('~/composables/useCrossPositionRepayCollateralOptions', () => ({
useCrossPositionRepayCollateralOptions: () => ({
items: ref(mocks.crossPositionItems),
}),
}))

vi.mock('~/composables/useEulerLabels', () => ({
useEulerProductOfVault: () => computed(() => 'Euler Earn'),
}))
Expand Down Expand Up @@ -164,6 +189,7 @@ vi.mock('~/composables/useSwapQuotesParallel', () => ({
})
return {
sortedQuoteCards: ref([]),
selectedQuoteCard: ref(null),
selectedProvider: ref(null),
selectedQuote,
effectiveQuote,
Expand All @@ -184,10 +210,13 @@ describe('useCollateralSwapRepay', () => {
let scope: EffectScope

beforeEach(() => {
vi.stubGlobal('useOperationIntentFactory', () => ({ create: vi.fn() }))
vi.stubGlobal('useOperationIntentFactory', () => ({ create: mocks.createIntent }))
vi.stubGlobal('useExecutionReview', () => ({ open: vi.fn() }))
vi.clearAllMocks()
mocks.quoteInstances.length = 0
mocks.crossPositionItems.length = 0
mocks.createIntent.mockImplementation(input => input)
mocks.planRepayFromSource.mockResolvedValue([])
mocks.getCollateralApySnapshot.mockResolvedValue({
supplyUsd: 1_000,
weightedSupplyApy: 1,
Expand All @@ -207,13 +236,17 @@ describe('useCollateralSwapRepay', () => {
effectiveAddress: ref(USER),
}))
vi.stubGlobal('useEulerTx', () => ({
planRepayFromSource: vi.fn(),
planRepayFromSource: mocks.planRepayFromSource,
executePlan: vi.fn(),
prefetchPluginData: vi.fn(),
}))
vi.stubGlobal('useEulerAddresses', () => ({ chainId: ref(1) }))
vi.stubGlobal('useTxFinalization', () => ({ finalizeTxAndRedirect: vi.fn() }))
vi.stubGlobal('useEulerAccount', () => ({ refreshAllPositions: vi.fn() }))
vi.stubGlobal('useEulerAccount', () => ({
borrowPositions: ref([position, sourcePosition]),
depositPositions: ref([]),
refreshAllPositions: vi.fn(),
}))
vi.stubGlobal('usePlanAccount', () => ({ account: shallowRef(planAccount) }))
vi.stubGlobal('useRpcClient', () => ({ client: ref(null) }))
vi.stubGlobal('useTxBatch', () => ({
Expand All @@ -222,7 +255,7 @@ describe('useCollateralSwapRepay', () => {
}))
vi.stubGlobal('useCowSwapEligibility', () => ({ cowSwapForcedOff: ref(false) }))
vi.stubGlobal('useUserSettings', () => ({
settings: ref({ enableIntrinsicApy: false }),
settings: ref({ enableIntrinsicApy: false, enableRewardsApy: false, enableAdvancedMode: true }),
}))
vi.stubGlobal('useRewardsApy', () => ({
getSupplyRewardApy: vi.fn(() => 0),
Expand Down Expand Up @@ -292,4 +325,82 @@ describe('useCollateralSwapRepay', () => {
},
))
})

it('builds and simulates an exact-vault cross-position share repayment without liquidity or early cleanup', async () => {
const selectionId = `${SOURCE_ACCOUNT.toLowerCase()}:${borrowVault.address.toLowerCase()}`
mocks.crossPositionItems.push({
id: selectionId,
vault: borrowVault,
sourceAccount: SOURCE_ACCOUNT,
assets: 2_500n,
shares: 2_500n,
option: {
selectionId,
type: 'vault',
amount: 2_500,
price: 2_500,
vaultAddress: borrowVault.address,
subAccount: SOURCE_ACCOUNT,
},
})

const runSimulation = vi.fn(async () => false)
const repay = scope.run(() => useCollateralSwapRepay({
position: shallowRef<PortfolioBorrowPosition<VaultEntity> | undefined>(position),
borrowVault: computed(() => borrowVault),
collateralVault: computed(() => sourceVault),
formTab: ref('collateral'),
plan: ref<TransactionPlan | null>(null),
isSubmitting: ref(false),
isPreparing: ref(false),
slippage: ref(0.5),
clearSimulationError: vi.fn(),
runSimulation,
getCurrentDebt: () => position.borrowed,
isEligibleForLiquidation: computed(() => false),
}))!

repay.initVault(sourceVault)
repay.onSourceVaultChange(0)
await nextTick()

expect(repay.selectedSourceAccount.value).toBe(SOURCE_ACCOUNT)
expect(repay.repayCollateralOptions.value[0]?.subAccount).toBe(SOURCE_ACCOUNT)
expect(repay.sourceVault.value?.address).toBe(borrowVault.address)
expect(repay.sourceAssets.value).toBe(2_500n)
expect(repay.sourceBalance.value).toBe(2_500n)
expect(repay.isSameVaultRepay.value).toBe(true)
expect(repay.isCrossPositionSource.value).toBe(true)

repay.debtAmount.value = '2000'
expect(repay.isSubmitDisabled.value).toBe(false)
expect(repay.disabledReason.value).toBeUndefined()

await repay.submit()
expect(runSimulation).toHaveBeenCalledWith([], {})

const built = await repay.buildRepayPlan()

expect(built).toEqual([])
expect(mocks.planRepayFromSource).toHaveBeenCalledWith(expect.objectContaining({
liabilityVault: borrowVault.address,
liabilityAmount: (2n ** 256n) - 1n,
receiver: USER,
fromVault: borrowVault.address,
fromAccount: SOURCE_ACCOUNT,
cleanupOnMax: false,
}))

repay.createRepayIntent()
expect(mocks.createIntent).toHaveBeenCalledWith(expect.objectContaining({
planner: 'repay-from-deposit',
args: expect.objectContaining({
receiver: USER,
fromVault: borrowVault.address,
fromAccount: SOURCE_ACCOUNT,
cleanupOnMax: false,
}),
subAccounts: [USER, SOURCE_ACCOUNT],
}))
})
})
Loading
Loading