From 2d0cbd291e1914d5ab97d7b3e76f4f190682f03a Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:24:52 +0100 Subject: [PATCH 1/5] feat: support cross-position collateral repayment Expose exact-vault collateral from another position in advanced mode and route reciprocal repayments through the transaction batch without liquidity-dependent withdrawals.\n\nAdd focused selection, planning, and calldata regression coverage. --- components/entities/asset/AssetInput.vue | 7 +- composables/repay/useCollateralSwapRepay.ts | 185 ++++++++++++++---- .../useCrossPositionRepayCollateralOptions.ts | 121 ++++++++++++ pages/position/[number]/repay.vue | 9 +- .../useCollateralSwapRepay.test.ts | 113 ++++++++++- ...rossPositionRepayCollateralOptions.test.ts | 93 +++++++++ tests/golden/cross-position-repay.test.ts | 83 ++++++++ types/collateral-option.ts | 2 + 8 files changed, 570 insertions(+), 43 deletions(-) create mode 100644 composables/useCrossPositionRepayCollateralOptions.ts create mode 100644 tests/composables/useCrossPositionRepayCollateralOptions.test.ts create mode 100644 tests/golden/cross-position-repay.test.ts diff --git a/components/entities/asset/AssetInput.vue b/components/entities/asset/AssetInput.vue index c1646e789..cce15a4a5 100644 --- a/components/entities/asset/AssetInput.vue +++ b/components/entities/asset/AssetInput.vue @@ -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 @@ -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 @@ -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)) diff --git a/composables/repay/useCollateralSwapRepay.ts b/composables/repay/useCollateralSwapRepay.ts index 878c87498..039ce568e 100644 --- a/composables/repay/useCollateralSwapRepay.ts +++ b/composables/repay/useCollateralSwapRepay.ts @@ -15,6 +15,7 @@ import { getAssetOraclePrice, conservativePriceRatioNumber } from '~/utils/sdk-p import { getBorrowPositionEffectiveLiquidationLTV } from '~/utils/ltv' import { maxUint256 } from 'viem' import { useSwapCollateralOptions } from '~/composables/useSwapCollateralOptions' +import { useCrossPositionRepayCollateralOptions, type CrossPositionRepayCollateralItem } from '~/composables/useCrossPositionRepayCollateralOptions' import { useEulerProductOfVault } from '~/composables/useEulerLabels' import { useRepaySwapCore } from '~/composables/repay/useRepaySwapCore' import { useRepaySwapDetails } from '~/composables/repay/useRepaySwapDetails' @@ -50,6 +51,7 @@ interface UseCollateralSwapRepayOptions { interface CollateralSwapRepayPlanSnapshot { sourceVault?: EVault + sourceAccount?: Address amount?: string debtAmount?: string direction?: SwapperMode @@ -85,7 +87,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const buildRepayStateOverrideOptions = () => buildStateOverrideOptions({ noBalanceOverride: true }) const { chainId: currentChainId } = useEulerAddresses() const { finalizeExecutionUi } = useTxFinalization() - const { refreshAllPositions } = useEulerAccount() + const { borrowPositions, depositPositions, refreshAllPositions } = useEulerAccount() const { account: planAccount } = usePlanAccount() const { client: rpcClient } = useRpcClient() const { entryCount: batchEntryCount, getMergedPlan } = useTxBatch() @@ -97,11 +99,20 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = // --- Source vault state --- const sourceVault: Ref = ref() + const selectedSourceAccount = ref
() + const selectedSourceId = ref() const sourceAssets = ref(0n) const sourceShares = ref(0n) + const targetAccount = computed(() => position.value?.subAccount as Address | undefined) + const isCrossPositionSource = computed(() => !!selectedSourceAccount.value + && !!targetAccount.value + && normalizeAddressOrEmpty(selectedSourceAccount.value) !== normalizeAddressOrEmpty(targetAccount.value)) + const isSameVaultRepay = computed(() => !!sourceVault.value + && !!borrowVault.value + && normalizeAddressOrEmpty(sourceVault.value.address) === normalizeAddressOrEmpty(borrowVault.value.address)) const sourceBalance = computed(() => getCashLimitedWithdrawAmount( sourceAssets.value, - sourceVault.value, + isSameVaultRepay.value ? undefined : sourceVault.value, )) const debtBalance = computed(() => position.value?.borrowed || 0n) @@ -124,7 +135,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = tagContext: 'supply-source', }) - const repayCollateralVaults = computed(() => { + const currentRepayCollateralVaults = computed(() => { if (!position.value) return [] const collateralAddresses = position.value.collateralVaults const allowed = collateralAddresses.length @@ -134,17 +145,51 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const filtered = allowed ? candidates.filter(vault => allowed.has(normalizeAddressOrEmpty(vault.address))) : candidates - if (!filtered.length && collateralVault.value) { + if (!filtered.length && collateralVault.value && isEVault(collateralVault.value)) { return [collateralVault.value] } return filtered }) - const repayCollateralOptions = computed(() => { - const allowed = new Set(repayCollateralVaults.value.map(vault => normalizeAddressOrEmpty(vault.address))) + const currentRepayCollateralOptions = computed(() => { + const allowed = new Set(currentRepayCollateralVaults.value.map(vault => normalizeAddressOrEmpty(vault.address))) return swapCollateralOptions.value.filter(option => allowed.has(normalizeAddressOrEmpty(option.vaultAddress))) }) + const currentRepayCollateralItems = computed(() => { + const currentPosition = position.value + if (!currentPosition) return [] + const sourceAccount = currentPosition.subAccount as Address + return currentRepayCollateralVaults.value.flatMap((vault) => { + const option = currentRepayCollateralOptions.value.find(candidate => + normalizeAddressOrEmpty(candidate.vaultAddress) === normalizeAddressOrEmpty(vault.address)) + if (!option) return [] + const collateral = currentPosition.collaterals.find(candidate => + normalizeAddressOrEmpty(candidate.vaultAddress) === normalizeAddressOrEmpty(vault.address)) + const id = `${sourceAccount.toLowerCase()}:${vault.address.toLowerCase()}` + return [{ + id, + vault, + sourceAccount, + assets: collateral?.assets ?? 0n, + shares: collateral?.shares ?? 0n, + option: { ...option, selectionId: id }, + }] + }) + }) + + const { items: crossPositionRepayCollateralItems } = useCrossPositionRepayCollateralOptions({ + targetPosition: position, + liabilityVault: borrowVault, + }) + + const repayCollateralItems = computed(() => [ + ...currentRepayCollateralItems.value, + ...crossPositionRepayCollateralItems.value, + ]) + const repayCollateralOptions = computed(() => repayCollateralItems.value.map(item => item.option)) + const repayCollateralVaults = computed(() => repayCollateralItems.value.map(item => item.vault)) + // --- Core swap logic --- const core = useRepaySwapCore({ position, @@ -158,15 +203,16 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = slippage, clearSimulationError, getCurrentDebt, - includeCowSwap: () => !cowSwapForcedOff.value && batchEntryCount.value === 0, + includeCowSwap: () => !isCrossPositionSource.value && !cowSwapForcedOff.value && batchEntryCount.value === 0, buildTxPlanForQuote: (quote, _provider, context) => buildRepayPlan(quote, context.account), createIntentsForQuote: quote => [createRepayIntent(quote)], buildGasEstimatePlan: buildBatchAwareGasEstimatePlan, prefetchPluginData: (plan, account, intents) => prefetchPluginData(plan, { account, intents }), getPlanAccount: () => planAccount.value, getQuoteAccounts: () => { - const subAccount = (position.value?.subAccount || effectiveAddress.value || zeroAddress) as Address - return { accountIn: subAccount, accountOut: subAccount } + const accountOut = (position.value?.subAccount || effectiveAddress.value || zeroAddress) as Address + const accountIn = (selectedSourceAccount.value || accountOut) as Address + return { accountIn, accountOut } }, }) @@ -207,6 +253,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = // --- Price ratio --- const priceRatio = computed(() => { + if (isCrossPositionSource.value) return null if (!sourceVault.value || !borrowVault.value) return null const collateralPrice = getAssetOraclePrice(sourceVault.value) const borrowPrice = getAssetOraclePrice(borrowVault.value) @@ -216,12 +263,14 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = // --- Collateral-specific computeds --- const collateralAmountAfter = computed(() => { + if (isCrossPositionSource.value) return null if (!sourceVault.value || core.spent.value === null) return null const nextAssets = sourceAssets.value - core.spent.value return nanoToValue(nextAssets > 0n ? nextAssets : 0n, sourceVault.value.shares.decimals) }) const nextLiquidationLtv = computed(() => { + if (isCrossPositionSource.value) return null if (!borrowVault.value || !sourceVault.value) return null const match = borrowVault.value.collaterals.find( ltv => normalizeAddressOrEmpty(ltv.address) === normalizeAddressOrEmpty(sourceVault.value?.address), @@ -280,12 +329,14 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const [currentSnapshot, nextSnapshot] = await Promise.all([ getCollateralApySnapshot(currentPosition, currentBorrowVault), getCollateralApySnapshot(currentPosition, currentBorrowVault, { - deltas: [{ - vaultAddress: currentSourceVault.address, - assetsDelta: -spent, - cashDelta: sourceIsLiability ? 0n : -spent, - projectRates: spent > 0n, - }], + deltas: isCrossPositionSource.value + ? [] + : [{ + vaultAddress: currentSourceVault.address, + assetsDelta: -spent, + cashDelta: sourceIsLiability ? 0n : -spent, + projectRates: spent > 0n, + }], ...(repayAmount !== null ? { liabilityRateDelta: { @@ -347,9 +398,10 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = return health.nextHealth.value < 1 }) - // Collateral-swap repay. Same-asset path: source.WITHDRAW + liability.SKIM - // + liability.REPAY_WITH_SHARES. Cross-asset path: source.WITHDRAW + swap + - // liability.REPAY (done by swapper). Full repay: + collateral.TRANSFER. + // Collateral-swap repay. Exact-vault path: liability.REPAY_WITH_SHARES only. + // Same-asset cross-vault path: source.WITHDRAW + liability.SKIM + + // liability.REPAY_WITH_SHARES. Cross-asset path: source.WITHDRAW + swap + + // liability.REPAY (done by swapper). Same-position full repay: + collateral.TRANSFER. // Heuristic: for cross-asset paths, core.debtRepaid uses the quote's // amountOut (pre-slippage). See useSavingsRepay for the precision note. const isEffectivelyFullRepay = computed(() => { @@ -360,10 +412,12 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const collateralSwapRepayPlannedOps = computed(() => { const steps: PlannedOp[] = [] - if (sourceVault.value) steps.push({ vault: sourceVault.value as EVault, op: OP_WITHDRAW }) + if (sourceVault.value && !isSameVaultRepay.value) steps.push({ vault: sourceVault.value as EVault, op: OP_WITHDRAW }) if (borrowVault.value) { - if (core.isSameAsset.value) { - // Same-asset: withdraw → skim → repayWithShares + if (isSameVaultRepay.value) { + steps.push({ vault: borrowVault.value as EVault, op: OP_REPAY_WITH_SHARES }) + } + else if (core.isSameAsset.value) { steps.push({ vault: borrowVault.value as EVault, op: OP_SKIM }) steps.push({ vault: borrowVault.value as EVault, op: OP_REPAY_WITH_SHARES }) } @@ -372,8 +426,8 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = steps.push({ vault: borrowVault.value as EVault, op: OP_REPAY }) } } - if (isEffectivelyFullRepay.value) { - for (const vault of repayCollateralVaults.value) { + if (isEffectivelyFullRepay.value && !isCrossPositionSource.value) { + for (const vault of currentRepayCollateralVaults.value) { if (isEVault(vault)) { steps.push({ vault, op: OP_TRANSFER }) } @@ -397,10 +451,12 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = }) const isInsufficientSource = computed(() => requiredInput.value > 0n && requiredInput.value > sourceAssets.value) const isInsufficientVaultLiquidity = computed(() => - requiredInput.value > 0n && requiredInput.value > (sourceVault.value?.availableLiquidity ?? 0n), + !isSameVaultRepay.value + && requiredInput.value > 0n + && requiredInput.value > (sourceVault.value?.availableLiquidity ?? 0n), ) const liquidityWarning = computed(() => { - if (!sourceVault.value) return null + if (!sourceVault.value || isSameVaultRepay.value) return null return getUtilisationWarning(sourceVault.value, 'repay') }) @@ -410,6 +466,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = if (findBlockingDisabledOp(collateralSwapRepayPlannedOps.value)) return true if (!sourceVault.value || !borrowVault.value) return true if (!core.debtAmount.value && !core.amount.value) return true + if (isCrossPositionSource.value) return true if (isInsufficientSource.value) return true if (isInsufficientVaultLiquidity.value) return true if (core.isSameAsset.value) { @@ -424,6 +481,9 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = }) const disabledReason = computed(() => { + if (isCrossPositionSource.value) { + return 'Cross-position collateral repayments must be added to a batch.' + } if (core.isRepayExceedsDebt.value) { return 'Repay amount exceeds outstanding debt' } @@ -446,17 +506,37 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = sourceShares.value = 0n return } - const primaryAddress = normalizeAddressOrEmpty(position.value.collateralVault?.address) + let sourcePosition = borrowPositions.value.find(candidate => + normalizeAddressOrEmpty(candidate.subAccount) === normalizeAddressOrEmpty(selectedSourceAccount.value)) + if (!sourcePosition && normalizeAddressOrEmpty(position.value.subAccount) === normalizeAddressOrEmpty(selectedSourceAccount.value)) { + sourcePosition = position.value + } + const sourceSaving = depositPositions.value.find(candidate => + candidate.position.isCollateral + && normalizeAddressOrEmpty(candidate.subAccount) === normalizeAddressOrEmpty(selectedSourceAccount.value) + && normalizeAddressOrEmpty(candidate.position.vaultAddress) === normalizeAddressOrEmpty(sourceVault.value?.address)) + if (!sourcePosition && !sourceSaving) { + sourceAssets.value = 0n + sourceShares.value = 0n + return + } + if (sourceSaving) { + sourceAssets.value = sourceSaving.assets + sourceShares.value = sourceSaving.shares + return + } + if (!sourcePosition) return + const primaryAddress = normalizeAddressOrEmpty(sourcePosition.collateralVault?.address) const targetAddress = normalizeAddressOrEmpty(sourceVault.value.address) // Source collateral assets/shares from the (layer-aware) position rather than // a direct lens read, so it reflects the active batch layer. Unheld ⇒ 0. - const match = position.value.collaterals.find(c => + const match = sourcePosition.collaterals.find(c => normalizeAddressOrEmpty(c.vaultAddress) === targetAddress) - sourceAssets.value = match?.assets ?? (targetAddress === primaryAddress ? (position.value.supplied || 0n) : 0n) + sourceAssets.value = match?.assets ?? (targetAddress === primaryAddress ? (sourcePosition.supplied || 0n) : 0n) sourceShares.value = match?.shares ?? 0n } - watch([sourceVault, position], () => { + watch([sourceVault, selectedSourceAccount, position, borrowPositions, depositPositions], () => { void updateSourceBalance() }, { immediate: true }) @@ -511,6 +591,11 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = } const subAccount = position.value.subAccount as Address + const sourceAccount = snapshot.sourceAccount ?? selectedSourceAccount.value ?? subAccount + const isCrossPosition = normalizeAddressOrEmpty(sourceAccount) !== normalizeAddressOrEmpty(subAccount) + if (isCrossPosition && normalizeAddressOrEmpty(source.address) !== normalizeAddressOrEmpty(borrowVault.value.address)) { + throw new Error('Cross-position collateral repayment requires the exact liability vault') + } const sameAsset = snapshot.isSameAsset ?? core.isSameAsset.value const amountInput = snapshot.amount ?? core.amount.value const debtAmountInput = snapshot.debtAmount ?? core.debtAmount.value @@ -547,10 +632,10 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = liabilityAmount, receiver: subAccount, fromVault: source.address as Address, - fromAccount: subAccount, + fromAccount: sourceAccount, swapQuote: sameAsset ? undefined : (quote || core.quotes.selectedQuote.value!), swapperMode: swapMode, - cleanupOnMax: isFullRepay, + cleanupOnMax: isFullRepay && !isCrossPosition, account, }) } @@ -562,6 +647,11 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const source = snapshot.sourceVault ?? sourceVault.value if (!position.value || !borrowVault.value || !source) throw new Error('Position or vaults not loaded') const subAccount = position.value.subAccount as Address + const sourceAccount = snapshot.sourceAccount ?? selectedSourceAccount.value ?? subAccount + const isCrossPosition = normalizeAddressOrEmpty(sourceAccount) !== normalizeAddressOrEmpty(subAccount) + if (isCrossPosition && normalizeAddressOrEmpty(source.address) !== normalizeAddressOrEmpty(borrowVault.value.address)) { + throw new Error('Cross-position collateral repayment requires the exact liability vault') + } const sameAsset = snapshot.isSameAsset ?? core.isSameAsset.value const amountInput = snapshot.amount ?? core.amount.value const debtAmountInput = snapshot.debtAmount ?? core.debtAmount.value @@ -579,11 +669,11 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = liabilityAmount: isFullRepay ? maxUint256 : debtNano, receiver: subAccount, fromVault: source.address as Address, - fromAccount: subAccount, - cleanupOnMax: isFullRepay, + fromAccount: sourceAccount, + cleanupOnMax: isFullRepay && !isCrossPosition, }, source: 'position/repay-collateral', - subAccounts: [subAccount], + subAccounts: isCrossPosition ? [subAccount, sourceAccount] : [subAccount], }) } const swapQuote = quote || core.quotes.selectedQuote.value @@ -736,6 +826,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const submit = async () => { if (isPreparing.value || isSubmitting.value || !position.value || !borrowVault.value || !sourceVault.value) return + if (isCrossPositionSource.value) return if (!core.isSameAsset.value && !core.quotes.selectedQuote.value) return // CowSwap path: skip plan building and simulation @@ -799,15 +890,35 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const initVault = (vault: EVault | undefined) => { sourceVault.value = vault + selectedSourceAccount.value = position.value?.subAccount as Address | undefined + selectedSourceId.value = vault && selectedSourceAccount.value + ? `${selectedSourceAccount.value.toLowerCase()}:${vault.address.toLowerCase()}` + : undefined } + watch(repayCollateralItems, (items) => { + if (!isCrossPositionSource.value || !selectedSourceId.value) return + if (items.some(item => item.id === selectedSourceId.value)) return + initVault(collateralVault.value && isEVault(collateralVault.value) ? collateralVault.value : undefined) + core.resetCore() + }) + const resetOnTabSwitch = () => { core.resetCore() core.direction.value = SwapperMode.EXACT_IN } const onSourceVaultChange = (selectedIndex: number) => { - core.onSourceVaultChange(selectedIndex, repayCollateralVaults) + const next = repayCollateralItems.value[selectedIndex] + if (!next) return + const changed = selectedSourceId.value !== next.id + selectedSourceId.value = next.id + selectedSourceAccount.value = next.sourceAccount + sourceVault.value = next.vault + if (changed) { + clearSimulationError() + core.resetCore() + } } return { @@ -817,6 +928,8 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = direction: core.direction, debtPercent: core.debtPercent, sourceVault, + selectedSourceAccount, + selectedSourceId, sourceAssets, sourceBalance, debtBalance, @@ -826,6 +939,8 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = repayCollateralVaults, quotes: core.quotes, isSameAsset: core.isSameAsset, + isSameVaultRepay, + isCrossPositionSource, spent: core.spent, debtRepaid: core.debtRepaid, // Health metrics diff --git a/composables/useCrossPositionRepayCollateralOptions.ts b/composables/useCrossPositionRepayCollateralOptions.ts new file mode 100644 index 000000000..a796e5ef3 --- /dev/null +++ b/composables/useCrossPositionRepayCollateralOptions.ts @@ -0,0 +1,121 @@ +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[] + savingsPositions?: readonly PortfolioSavingsPosition[] + targetPosition?: PortfolioBorrowPosition + liabilityVault?: EVault + enabled: boolean +}) => { + if (!enabled || !targetPosition || !liabilityVault) return [] + + const targetAccount = getAddress(targetPosition.subAccount) + const liabilityVaultAddress = getAddress(liabilityVault.address) + const candidates = new Map() + + 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 | undefined> + liabilityVault: Ref +}) => { + 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', + }), + selectionId: candidate.id, + }, + } satisfies CrossPositionRepayCollateralItem), + ) + + return { items } +} diff --git a/pages/position/[number]/repay.vue b/pages/position/[number]/repay.vue index 41b9abbc4..2b0f12a9f 100644 --- a/pages/position/[number]/repay.vue +++ b/pages/position/[number]/repay.vue @@ -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 @@ -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 } @@ -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 diff --git a/tests/composables/useCollateralSwapRepay.test.ts b/tests/composables/useCollateralSwapRepay.test.ts index ac193d0f1..0da1607f0 100644 --- a/tests/composables/useCollateralSwapRepay.test.ts +++ b/tests/composables/useCollateralSwapRepay.test.ts @@ -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 @@ -55,15 +56,33 @@ const { USER, SOURCE_VAULT, sourceVault, borrowVault, position, planAccount, moc }], } as unknown as PortfolioBorrowPosition + 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 + return { USER, + SOURCE_ACCOUNT, SOURCE_VAULT, sourceVault, borrowVault, position, + sourcePosition, planAccount: { chainId: 1 } as Account, mocks: { getCollateralApySnapshot: vi.fn(), + createIntent: vi.fn(), + planRepayFromSource: vi.fn(), + crossPositionItems: [] as Array>, quoteInstances: [] as Array<{ amountField: 'amountIn' | 'amountOut' selectedQuote: { value: SwapQuote | null } @@ -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'), })) @@ -184,10 +209,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, @@ -207,13 +235,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', () => ({ @@ -222,7 +254,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), @@ -292,4 +324,75 @@ describe('useCollateralSwapRepay', () => { }, )) }) + + it('builds 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, + }, + }) + + const repay = scope.run(() => useCollateralSwapRepay({ + position: shallowRef | undefined>(position), + borrowVault: computed(() => borrowVault), + collateralVault: computed(() => sourceVault), + formTab: ref('collateral'), + plan: ref(null), + isSubmitting: ref(false), + isPreparing: ref(false), + slippage: ref(0.5), + clearSimulationError: vi.fn(), + runSimulation: vi.fn(async () => true), + getCurrentDebt: () => position.borrowed, + isEligibleForLiquidation: computed(() => false), + }))! + + repay.initVault(sourceVault) + repay.onSourceVaultChange(0) + await nextTick() + + expect(repay.selectedSourceAccount.value).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) + expect(repay.isSubmitDisabled.value).toBe(true) + expect(repay.disabledReason.value).toBe('Cross-position collateral repayments must be added to a batch.') + + repay.debtAmount.value = '2000' + 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], + })) + }) }) diff --git a/tests/composables/useCrossPositionRepayCollateralOptions.test.ts b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts new file mode 100644 index 000000000..7466dc963 --- /dev/null +++ b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from 'vitest' +import type { Address } from 'viem' +import type { EVault, PortfolioBorrowPosition, PortfolioSavingsPosition, VaultEntity } from '@eulerxyz/euler-v2-sdk' +import { buildCrossPositionRepayCollateralCandidates } from '~/composables/useCrossPositionRepayCollateralOptions' + +const TARGET_ACCOUNT = '0x0000000000000000000000000000000000000001' as Address +const SOURCE_ACCOUNT = '0x0000000000000000000000000000000000000002' as Address +const OTHER_ACCOUNT = '0x0000000000000000000000000000000000000003' as Address +const LIABILITY_VAULT = '0x0000000000000000000000000000000000000010' as Address +const OTHER_VAULT = '0x0000000000000000000000000000000000000020' as Address + +const liabilityVault = { + address: LIABILITY_VAULT, + asset: { address: '0x0000000000000000000000000000000000000030', symbol: 'USDC', decimals: 6 }, + shares: { address: LIABILITY_VAULT, symbol: 'eUSDC', decimals: 6 }, + collaterals: [], +} as unknown as EVault + +const position = ( + subAccount: Address, + collaterals: Array<{ vaultAddress: Address, assets: bigint, shares: bigint }>, +) => ({ subAccount, collaterals }) as unknown as PortfolioBorrowPosition + +describe('buildCrossPositionRepayCollateralCandidates', () => { + const target = position(TARGET_ACCOUNT, [{ vaultAddress: OTHER_VAULT, assets: 10n, shares: 10n }]) + const exactVaultSource = position(SOURCE_ACCOUNT, [{ vaultAddress: LIABILITY_VAULT, assets: 25n, shares: 24n }]) + const crossVaultSource = position(OTHER_ACCOUNT, [{ vaultAddress: OTHER_VAULT, assets: 30n, shares: 30n }]) + + it('exposes only positive exact-vault collateral from other positions when enabled', () => { + const result = buildCrossPositionRepayCollateralCandidates({ + positions: [target, exactVaultSource, crossVaultSource], + targetPosition: target, + liabilityVault, + enabled: true, + }) + + expect(result).toEqual([expect.objectContaining({ + id: `${SOURCE_ACCOUNT.toLowerCase()}:${LIABILITY_VAULT.toLowerCase()}`, + vault: liabilityVault, + sourceAccount: SOURCE_ACCOUNT, + assets: 25n, + shares: 24n, + })]) + }) + + it('does not expose cross-position collateral outside advanced mode', () => { + expect(buildCrossPositionRepayCollateralCandidates({ + positions: [target, exactVaultSource], + targetPosition: target, + liabilityVault, + enabled: false, + })).toEqual([]) + }) + + it('excludes zero balances and deduplicates the same vault position', () => { + const empty = position(OTHER_ACCOUNT, [{ vaultAddress: LIABILITY_VAULT, assets: 0n, shares: 1n }]) + const result = buildCrossPositionRepayCollateralCandidates({ + positions: [target, exactVaultSource, exactVaultSource, empty], + targetPosition: target, + liabilityVault, + enabled: true, + }) + + expect(result).toHaveLength(1) + expect(result[0]?.sourceAccount).toBe(SOURCE_ACCOUNT) + }) + + it('retains collateral-enabled savings projected after the reciprocal debt is repaid', () => { + const projectedSaving = { + subAccount: SOURCE_ACCOUNT, + assets: 20n, + shares: 19n, + position: { + vaultAddress: LIABILITY_VAULT, + isCollateral: true, + }, + } as unknown as PortfolioSavingsPosition + + const result = buildCrossPositionRepayCollateralCandidates({ + positions: [target], + savingsPositions: [projectedSaving], + targetPosition: target, + liabilityVault, + enabled: true, + }) + + expect(result).toEqual([expect.objectContaining({ + sourceAccount: SOURCE_ACCOUNT, + assets: 20n, + shares: 19n, + })]) + }) +}) diff --git a/tests/golden/cross-position-repay.test.ts b/tests/golden/cross-position-repay.test.ts new file mode 100644 index 000000000..677a2c069 --- /dev/null +++ b/tests/golden/cross-position-repay.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { decodeFunctionData, maxUint256, parseAbi, toFunctionSelector } from 'viem' +import { ADDR, buildSdkAccount, buildSdkExecutionService } from './harness' +import { normalizeSdkPlan } from './normalize' + +const repayWithSharesAbi = parseAbi(['function repayWithShares(uint256 amount, address receiver)']) +const repayWithSharesSelector = toFunctionSelector('repayWithShares(uint256,address)') +const disableControllerSelector = toFunctionSelector('disableController()') +const transferFromMaxSelector = toFunctionSelector('transferFromMax(address,address)') +const withdrawSelector = toFunctionSelector('withdraw(uint256,address,address)') +const skimSelector = toFunctionSelector('skim(uint256,address)') + +describe('cross-position repay calldata', () => { + it('repays reciprocal debts with shares in one atomic batch without withdrawing liquidity', () => { + const service = buildSdkExecutionService() + const account = buildSdkAccount({ + positions: [ + { subAccount: ADDR.user, vault: ADDR.vaultUsdc, asset: ADDR.assetUsdc, shares: 2_000_000n, assets: 2_000_000n, isCollateral: true }, + { subAccount: ADDR.user, vault: ADDR.vaultDai, asset: ADDR.assetDai, borrowed: 1_000_000n }, + { subAccount: ADDR.subAccount1, vault: ADDR.vaultDai, asset: ADDR.assetDai, shares: 2_000_000n, assets: 2_000_000n, isCollateral: true }, + { subAccount: ADDR.subAccount1, vault: ADDR.vaultUsdc, asset: ADDR.assetUsdc, borrowed: 1_000_000n }, + ], + }) + + const repayUsdc = service.planRepayFromDeposit({ + account, + liabilityVault: ADDR.vaultUsdc, + liabilityAmount: maxUint256, + receiver: ADDR.subAccount1, + fromVault: ADDR.vaultUsdc, + fromAccount: ADDR.user, + cleanupOnMax: false, + }) + const repayDai = service.planRepayFromDeposit({ + account, + liabilityVault: ADDR.vaultDai, + liabilityAmount: maxUint256, + receiver: ADDR.user, + fromVault: ADDR.vaultDai, + fromAccount: ADDR.subAccount1, + cleanupOnMax: false, + }) + + const transactions = normalizeSdkPlan(service.mergePlans([repayUsdc, repayDai]), ADDR.evc) + expect(transactions).toHaveLength(1) + const calls = transactions[0]?.evcBatch ?? [] + const repayCalls = calls.filter(call => call.selector === repayWithSharesSelector) + + expect(repayCalls).toHaveLength(2) + expect(repayCalls.map((call) => { + const decoded = decodeFunctionData({ abi: repayWithSharesAbi, data: call.data }) + return { + vault: call.targetContract, + fromAccount: call.onBehalfOfAccount, + amount: decoded.args[0], + receiver: decoded.args[1], + } + })).toEqual([ + { + vault: ADDR.vaultUsdc, + fromAccount: ADDR.user, + amount: maxUint256, + receiver: ADDR.subAccount1, + }, + { + vault: ADDR.vaultDai, + fromAccount: ADDR.subAccount1, + amount: maxUint256, + receiver: ADDR.user, + }, + ]) + expect(calls.filter(call => call.selector === disableControllerSelector).map(call => ({ + vault: call.targetContract, + account: call.onBehalfOfAccount, + }))).toEqual([ + { vault: ADDR.vaultUsdc, account: ADDR.subAccount1 }, + { vault: ADDR.vaultDai, account: ADDR.user }, + ]) + expect(calls.some(call => call.selector === withdrawSelector)).toBe(false) + expect(calls.some(call => call.selector === skimSelector)).toBe(false) + expect(calls.some(call => call.selector === transferFromMaxSelector)).toBe(false) + }) +}) diff --git a/types/collateral-option.ts b/types/collateral-option.ts index 5143e67e4..6fe628379 100644 --- a/types/collateral-option.ts +++ b/types/collateral-option.ts @@ -1,6 +1,8 @@ import type { VaultEntity } from '@eulerxyz/euler-v2-sdk' export interface CollateralOption { + /** Stable internal identity for otherwise identical options. Never rendered. */ + selectionId?: string type: string amount: number price: number From f929384f699c0c1f6941afb35811267a29c295ff Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:33:24 +0100 Subject: [PATCH 2/5] fix: retry preview SDK checkout Fall back to a normal shallow clone when Railway cannot complete the filtered partial clone for a preview SDK branch. --- scripts/install-preview-sdk.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/install-preview-sdk.sh b/scripts/install-preview-sdk.sh index c4a5e2afa..847aa9b7e 100644 --- a/scripts/install-preview-sdk.sh +++ b/scripts/install-preview-sdk.sh @@ -29,7 +29,11 @@ if ! command -v git >/dev/null 2>&1; then fi rm -rf "$SDK_DIR" "$SDK_PACK_DIR" -git clone --filter=blob:none --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR" +if ! git clone --filter=blob:none --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR"; then + echo "Filtered SDK clone failed; retrying without partial-clone filtering." + rm -rf "$SDK_DIR" + git clone --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR" +fi cd "$SDK_DIR" if [ -n "$APP_SDK_VERSION" ]; then From 765db0ed12441c8d8c095a7f7a2149e251c98527 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:35:50 +0100 Subject: [PATCH 3/5] Revert "fix: retry preview SDK checkout" This reverts commit f929384f699c0c1f6941afb35811267a29c295ff. --- scripts/install-preview-sdk.sh | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/install-preview-sdk.sh b/scripts/install-preview-sdk.sh index 847aa9b7e..c4a5e2afa 100644 --- a/scripts/install-preview-sdk.sh +++ b/scripts/install-preview-sdk.sh @@ -29,11 +29,7 @@ if ! command -v git >/dev/null 2>&1; then fi rm -rf "$SDK_DIR" "$SDK_PACK_DIR" -if ! git clone --filter=blob:none --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR"; then - echo "Filtered SDK clone failed; retrying without partial-clone filtering." - rm -rf "$SDK_DIR" - git clone --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR" -fi +git clone --filter=blob:none --depth=1 --branch "$EULER_SDK_BRANCH" "$SDK_REPO" "$SDK_DIR" cd "$SDK_DIR" if [ -n "$APP_SDK_VERSION" ]; then From ef98032b36041b5d6c5af1666764c46719845667 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:28:53 +0100 Subject: [PATCH 4/5] fix: identify collateral source positions Show the existing Position N badge for current and cross-position repay collateral sources, with focused coverage for source-account metadata. --- composables/repay/useCollateralSwapRepay.ts | 2 +- .../useCrossPositionRepayCollateralOptions.ts | 1 + .../useCollateralSwapRepay.test.ts | 2 + ...rossPositionRepayCollateralOptions.test.ts | 48 ++++++++++++++++++- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/composables/repay/useCollateralSwapRepay.ts b/composables/repay/useCollateralSwapRepay.ts index 039ce568e..5cdd1331a 100644 --- a/composables/repay/useCollateralSwapRepay.ts +++ b/composables/repay/useCollateralSwapRepay.ts @@ -173,7 +173,7 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = sourceAccount, assets: collateral?.assets ?? 0n, shares: collateral?.shares ?? 0n, - option: { ...option, selectionId: id }, + option: { ...option, selectionId: id, subAccount: sourceAccount }, }] }) }) diff --git a/composables/useCrossPositionRepayCollateralOptions.ts b/composables/useCrossPositionRepayCollateralOptions.ts index a796e5ef3..206cb5d84 100644 --- a/composables/useCrossPositionRepayCollateralOptions.ts +++ b/composables/useCrossPositionRepayCollateralOptions.ts @@ -111,6 +111,7 @@ export const useCrossPositionRepayCollateralOptions = ({ enableRewardsApy: enableRewardsApy.value, }), tagContext: 'supply-source', + subAccount: candidate.sourceAccount, }), selectionId: candidate.id, }, diff --git a/tests/composables/useCollateralSwapRepay.test.ts b/tests/composables/useCollateralSwapRepay.test.ts index 0da1607f0..0f013cc36 100644 --- a/tests/composables/useCollateralSwapRepay.test.ts +++ b/tests/composables/useCollateralSwapRepay.test.ts @@ -339,6 +339,7 @@ describe('useCollateralSwapRepay', () => { amount: 2_500, price: 2_500, vaultAddress: borrowVault.address, + subAccount: SOURCE_ACCOUNT, }, }) @@ -362,6 +363,7 @@ describe('useCollateralSwapRepay', () => { 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) diff --git a/tests/composables/useCrossPositionRepayCollateralOptions.test.ts b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts index 7466dc963..a8372e25e 100644 --- a/tests/composables/useCrossPositionRepayCollateralOptions.test.ts +++ b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts @@ -1,7 +1,24 @@ -import { describe, expect, it } from 'vitest' +import { effectScope, ref, shallowRef } from 'vue' +import { describe, expect, it, vi } from 'vitest' import type { Address } from 'viem' import type { EVault, PortfolioBorrowPosition, PortfolioSavingsPosition, VaultEntity } from '@eulerxyz/euler-v2-sdk' -import { buildCrossPositionRepayCollateralCandidates } from '~/composables/useCrossPositionRepayCollateralOptions' +import { buildCrossPositionRepayCollateralCandidates, useCrossPositionRepayCollateralOptions } from '~/composables/useCrossPositionRepayCollateralOptions' + +const { buildCollateralOption } = vi.hoisted(() => ({ + buildCollateralOption: vi.fn(async ({ vault, type, amount, subAccount }) => ({ + type, + amount, + price: amount, + symbol: vault.asset.symbol, + vaultAddress: vault.address, + subAccount, + })), +})) + +vi.mock('~/utils/collateralOptions', () => ({ + buildCollateralOption, + computeSupplyApy: vi.fn(() => 0), +})) const TARGET_ACCOUNT = '0x0000000000000000000000000000000000000001' as Address const SOURCE_ACCOUNT = '0x0000000000000000000000000000000000000002' as Address @@ -90,4 +107,31 @@ describe('buildCrossPositionRepayCollateralCandidates', () => { shares: 19n, })]) }) + + it('exposes the source account to the existing position badge in the asset modal', async () => { + vi.stubGlobal('useEulerAccount', () => ({ + borrowPositions: ref([target, exactVaultSource]), + depositPositions: ref([]), + })) + vi.stubGlobal('useUserSettings', () => ({ + settings: ref({ enableAdvancedMode: true, enableIntrinsicApy: false, enableRewardsApy: false }), + })) + vi.stubGlobal('useApyVisibility', () => ({ viewer: ref(undefined) })) + vi.stubGlobal('nanoToValue', (amount: bigint, decimals: number) => Number(amount) / 10 ** decimals) + + const scope = effectScope() + const result = scope.run(() => useCrossPositionRepayCollateralOptions({ + targetPosition: shallowRef | undefined>(target), + liabilityVault: shallowRef(liabilityVault), + }))! + + await vi.waitFor(() => expect(result.items.value).toHaveLength(1)) + expect(buildCollateralOption).toHaveBeenCalledWith(expect.objectContaining({ + subAccount: SOURCE_ACCOUNT, + })) + expect(result.items.value[0]?.option.subAccount).toBe(SOURCE_ACCOUNT) + + scope.stop() + vi.unstubAllGlobals() + }) }) From 790d6b677b0df0a305b8f7732c36ef1e5a9eca2c Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:43:10 +0100 Subject: [PATCH 5/5] fix: allow direct cross-position repayment Let preflight simulation determine whether a standalone cross-account share repayment is safe while preserving the batch path for reciprocal exits. --- composables/repay/useCollateralSwapRepay.ts | 5 ----- tests/composables/useCollateralSwapRepay.test.ts | 14 ++++++++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/composables/repay/useCollateralSwapRepay.ts b/composables/repay/useCollateralSwapRepay.ts index 5cdd1331a..7e8f6b34b 100644 --- a/composables/repay/useCollateralSwapRepay.ts +++ b/composables/repay/useCollateralSwapRepay.ts @@ -466,7 +466,6 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = if (findBlockingDisabledOp(collateralSwapRepayPlannedOps.value)) return true if (!sourceVault.value || !borrowVault.value) return true if (!core.debtAmount.value && !core.amount.value) return true - if (isCrossPositionSource.value) return true if (isInsufficientSource.value) return true if (isInsufficientVaultLiquidity.value) return true if (core.isSameAsset.value) { @@ -481,9 +480,6 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = }) const disabledReason = computed(() => { - if (isCrossPositionSource.value) { - return 'Cross-position collateral repayments must be added to a batch.' - } if (core.isRepayExceedsDebt.value) { return 'Repay amount exceeds outstanding debt' } @@ -826,7 +822,6 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = const submit = async () => { if (isPreparing.value || isSubmitting.value || !position.value || !borrowVault.value || !sourceVault.value) return - if (isCrossPositionSource.value) return if (!core.isSameAsset.value && !core.quotes.selectedQuote.value) return // CowSwap path: skip plan building and simulation diff --git a/tests/composables/useCollateralSwapRepay.test.ts b/tests/composables/useCollateralSwapRepay.test.ts index 0f013cc36..031666eed 100644 --- a/tests/composables/useCollateralSwapRepay.test.ts +++ b/tests/composables/useCollateralSwapRepay.test.ts @@ -189,6 +189,7 @@ vi.mock('~/composables/useSwapQuotesParallel', () => ({ }) return { sortedQuoteCards: ref([]), + selectedQuoteCard: ref(null), selectedProvider: ref(null), selectedQuote, effectiveQuote, @@ -325,7 +326,7 @@ describe('useCollateralSwapRepay', () => { )) }) - it('builds an exact-vault cross-position share repayment without liquidity or early cleanup', async () => { + 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, @@ -343,6 +344,7 @@ describe('useCollateralSwapRepay', () => { }, }) + const runSimulation = vi.fn(async () => false) const repay = scope.run(() => useCollateralSwapRepay({ position: shallowRef | undefined>(position), borrowVault: computed(() => borrowVault), @@ -353,7 +355,7 @@ describe('useCollateralSwapRepay', () => { isPreparing: ref(false), slippage: ref(0.5), clearSimulationError: vi.fn(), - runSimulation: vi.fn(async () => true), + runSimulation, getCurrentDebt: () => position.borrowed, isEligibleForLiquidation: computed(() => false), }))! @@ -369,10 +371,14 @@ describe('useCollateralSwapRepay', () => { expect(repay.sourceBalance.value).toBe(2_500n) expect(repay.isSameVaultRepay.value).toBe(true) expect(repay.isCrossPositionSource.value).toBe(true) - expect(repay.isSubmitDisabled.value).toBe(true) - expect(repay.disabledReason.value).toBe('Cross-position collateral repayments must be added to a batch.') 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([])