diff --git a/components/BatchReviewModal.vue b/components/BatchReviewModal.vue index 521d1669f..bd6872df9 100644 --- a/components/BatchReviewModal.vue +++ b/components/BatchReviewModal.vue @@ -433,10 +433,11 @@ const hasTenderlyFailed = computed(() => Boolean(tenderlyUrl.value && tenderlyEr const simulateOnTenderly = () => simulateBatchOnTenderly(preparedExecution.value ?? undefined) const isConfirmDisabled = computed(() => - isSpyMode.value || isExecuting.value || hasPendingDetachedExecution.value || isPreparing.value || isSimulating.value || !canExecuteBatch.value || !!prepareError.value, + isSpyMode.value || preparedExecution.value?.readOnly === true || isExecuting.value || hasPendingDetachedExecution.value || isPreparing.value || isSimulating.value || !canExecuteBatch.value || !!prepareError.value, ) const blockedReason = computed(() => { if (isSpyMode.value) return 'Connect a wallet to execute — disabled in spy mode' + if (preparedExecution.value?.readOnly) return 'This read-only review cannot be executed' if (hasFailedOps.value) return 'Resolve the reverting operation to execute' if (hasInsufficientBalance.value) return insufficientBalanceMessage.value || 'Not enough balance to execute this batch' if (simError.value) return 'This batch would revert — resolve the flagged error' @@ -451,7 +452,7 @@ let executionHandle: TrackedExecutionHandle | null = null const handleExecute = async () => { if (isConfirmDisabled.value) return const prepared = preparedExecution.value - if (!prepared) return + if (!prepared || prepared.readOnly) return // Latch the wallet classification at submission time; the single-slot gate // rejects new submissions while a detached proposal is pending. const handle = beginTrackedExecution({ safeAtSubmit: prepared.execution.requestSet.wallet.walletKind === 'safe' }) 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/components/entities/operation/OperationReviewModal.vue b/components/entities/operation/OperationReviewModal.vue index 5c6976196..33628c8ee 100644 --- a/components/entities/operation/OperationReviewModal.vue +++ b/components/entities/operation/OperationReviewModal.vue @@ -25,7 +25,7 @@ interface REULUnlockInfo { daysUntilMaturity: number } -const { type, asset, assetIconUrl, reulUnlockInfo, amount, reviewId, reviewDigest, reviewedAccount, reviewedWalletKind, reviewedRequests, reviewedSignatureSlots, externalSubmitting, plan, prepared, calldataPrepared, calldataUsesPlaceholderSignatures, calldataWrapCalls, tenderlyPrepared, tenderlyPlan, tenderlyStateOverrides, displayPlan, signatureSteps: providedSignatureSteps, postSteps, swapFromAsset, swapFromAmount, swapToAsset, swapToAmount, swapMode, swapEstimatedSide, supplyingAssetForBorrow, supplyingAmount, transferAmounts, vaultAmounts, knownAssets, swapQuoteOutputs, confirmLabel: providedConfirmLabel, submittingLabel, quoteFetchedAt, hideExecute, subAccount, marketLabel, allowConfirmWithoutPlan } = defineProps<{ +const { type, asset, assetIconUrl, reulUnlockInfo, amount, reviewId, reviewDigest, reviewedAccount, reviewedWalletKind, reviewedRequests, reviewedSignatureSlots, externalSubmitting, plan, prepared, calldataPrepared, calldataUsesPlaceholderSignatures, calldataWrapCalls, tenderlyPrepared, tenderlyPlan, tenderlyStateOverrides, displayPlan, signatureSteps: providedSignatureSteps, postSteps, swapFromAsset, swapFromAmount, swapToAsset, swapToAmount, swapMode, swapEstimatedSide, supplyingAssetForBorrow, supplyingAmount, transferAmounts, vaultAmounts, knownAssets, swapQuoteOutputs, confirmLabel: providedConfirmLabel, submittingLabel, quoteFetchedAt, hideExecute, readOnly, subAccount, marketLabel, allowConfirmWithoutPlan } = defineProps<{ type?: 'supply' | 'withdraw' | 'borrow' | 'repay' | 'swap' | 'transfer' | 'refinance' | 'migration' | 'reward' | 'brevis-reward' | 'fuul-reward' | 'turtle-reward' | 'reul-unlock' | 'disableCollateral' | 'swap-supply' | 'swap-withdraw' | 'swap-borrow' asset: VaultAsset assetIconUrl?: string @@ -86,6 +86,8 @@ const { type, asset, assetIconUrl, reulUnlockInfo, amount, reviewId, reviewDiges quoteFetchedAt?: number | null /** Read-only review (e.g. opened from a batch item): hides the execute button. */ hideExecute?: boolean + /** Prepared without a live wallet binding; it can be inspected but not submitted. */ + readOnly?: boolean /** Overrides the inferred Euler product name for non-product contexts, such as Earn vaults. */ marketLabel?: string /** Allow display-step-only reviews when the executable plan needs a confirm-time wallet authorization first. */ @@ -414,10 +416,11 @@ const isSwapQuoteStale = computed(() => { const permit2DisclaimerText = 'You are granting the Permit2 contract an unlimited token allowance. Permit2 is a Uniswap contract used to authorize future transfers with signatures. Each future transfer still requires your explicit signature and can be limited by amount and duration.' const hasDisplayOnlyConfirmation = computed(() => allowConfirmWithoutPlan && (displaySteps.value.length > 0 || signatureSteps.value.length > 0)) -const isConfirmDisabled = computed(() => isSpyMode.value || internalSubmitting.value || hasPendingDetachedExecution.value || isPreparingPlan.value || isResolvingStateOverrideHints.value || !!prepareError.value || (!reviewPlan.value?.length && !hasDisplayOnlyConfirmation.value)) +const isConfirmDisabled = computed(() => readOnly || isSpyMode.value || internalSubmitting.value || hasPendingDetachedExecution.value || isPreparingPlan.value || isResolvingStateOverrideHints.value || !!prepareError.value || (!reviewPlan.value?.length && !hasDisplayOnlyConfirmation.value)) const isTenderlyPreparing = computed(() => isTenderlySimulating.value || isBuildingTenderlyPayload.value) const confirmLabel = computed(() => { if (isSpyMode.value) return 'Spy mode (read-only)' + if (readOnly) return 'Read-only review' if (hasPendingDetachedExecution.value && !internalSubmitting.value) return 'Awaiting Safe signatures…' if (isPreparingPlan.value || isResolvingStateOverrideHints.value) return 'Preparing...' return internalSubmitting.value && submittingLabel ? submittingLabel : (providedConfirmLabel || btnLabel.value) diff --git a/composables/repay/useCollateralSwapRepay.ts b/composables/repay/useCollateralSwapRepay.ts index 878c87498..7e8f6b34b 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, subAccount: sourceAccount }, + }] + }) + }) + + 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') }) @@ -446,17 +502,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 +587,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 +628,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 +643,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 +665,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 @@ -799,15 +885,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 +923,8 @@ export const useCollateralSwapRepay = (options: UseCollateralSwapRepayOptions) = direction: core.direction, debtPercent: core.debtPercent, sourceVault, + selectedSourceAccount, + selectedSourceId, sourceAssets, sourceBalance, debtBalance, @@ -826,6 +934,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..206cb5d84 --- /dev/null +++ b/composables/useCrossPositionRepayCollateralOptions.ts @@ -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[] + 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', + subAccount: candidate.sourceAccount, + }), + selectionId: candidate.id, + }, + } satisfies CrossPositionRepayCollateralItem), + ) + + return { items } +} diff --git a/composables/useExecutionReview.ts b/composables/useExecutionReview.ts index c9a405eae..248541f6d 100644 --- a/composables/useExecutionReview.ts +++ b/composables/useExecutionReview.ts @@ -1,6 +1,6 @@ import type { Hash, StateOverride } from 'viem' import type { TransactionPlanPrepared } from '@eulerxyz/euler-v2-sdk' -import { ReviewedOperationModal } from '#components' +import { OperationReviewModal, ReviewedOperationModal } from '#components' import { useModal } from '~/components/ui/composables/useModal' import type { OperationIntent } from '~/features/reviewed-execution/domain/intents' import type { SubmissionResult } from '~/features/reviewed-execution/coordinator/coordinator' @@ -30,11 +30,37 @@ export interface OpenExecutionReviewOptions { export const useExecutionReview = () => { const modal = useModal() const execution = useReviewedExecution() + const { isSpyMode } = useEffectiveAddress() const open = async ( intents: readonly OperationIntent[], options: OpenExecutionReviewOptions, ): Promise<{ reviewId: Hash, reviewDigest: Hash }> => { + if (isSpyMode.value) { + const prepared = await execution.prepareReadOnly(intents, { + presentationKind: options.presentationKind, + presentationInputs: options.review, + }) + modal.open(OperationReviewModal, { + props: { + ...options.review, + plan: undefined, + prepared: prepared.prepared, + calldataPrepared: prepared.prepared, + tenderlyPrepared: options.tenderlyPrepared ?? prepared.prepared, + tenderlyStateOverrides: options.tenderlyStateOverrides, + reviewedAccount: prepared.execution.requestSet.wallet.account, + reviewedWalletKind: prepared.execution.requestSet.wallet.walletKind, + reviewedRequests: prepared.execution.requestSet.requests, + reviewedSignatureSlots: prepared.execution.requestSet.signatureSlots, + readOnly: true, + }, + }) + return { + reviewId: prepared.execution.reviewId, + reviewDigest: prepared.execution.reviewDigest, + } + } const prepared = await execution.prepare(intents, { presentationKind: options.presentationKind, presentationInputs: options.review, diff --git a/composables/useReviewedExecution.ts b/composables/useReviewedExecution.ts index 2c425f820..7f38a5411 100644 --- a/composables/useReviewedExecution.ts +++ b/composables/useReviewedExecution.ts @@ -42,6 +42,8 @@ const COMPILER_VERSION = 'lite-reviewed-execution-v2' const CLASSIFICATION_VERSION = 'safe-classification-v2' const POLICY_VERSION = 'lite-policy-v2' const PREPARATION_CACHE_TTL_MS = 60_000 +const READ_ONLY_CONNECTOR_ID = 'spy-mode-read-only' +const READ_ONLY_CLASSIFICATION_VERSION = 'spy-mode-read-only-v1' const PERMIT2_ALLOWANCE_ABI = [{ type: 'function', @@ -80,6 +82,8 @@ export interface PreparedExecutionReview { execution: Readonly previewPlan: TransactionPlan prepared: TransactionPlanPrepared + /** Read-only previews are never registered as execution authority. */ + readOnly?: true } const cache = new PreparationCache() @@ -157,6 +161,32 @@ const captureWalletBinding = async ( } } +export const createReadOnlyWalletBinding = ({ + account, + chainId, + subAccounts, +}: { + account: Address + chainId: number + subAccounts: readonly Address[] +}): WalletBinding => { + const normalizedAccount = getAddress(account) + const normalizedSubAccounts = subAccounts.map(getAddress) + return { + chainId, + account: normalizedAccount, + subAccounts: normalizedSubAccounts, + connectorId: READ_ONLY_CONNECTOR_ID, + connectorSessionId: canonicalDigest('spy-mode-read-only-session-v1', toCanonicalValue({ + account: normalizedAccount, + chainId, + })), + walletKind: 'eoa', + classificationVersion: READ_ONLY_CLASSIFICATION_VERSION, + approvalMode: 'approve', + } +} + const loadPlanningAccount = async (owner: Address, chainId: number): Promise> => { const warmed = useFreshAccount().account.value if (warmed) { @@ -201,20 +231,39 @@ export const useReviewedExecution = () => { const { signTypedDataAsync } = useSignTypedData() const { signaturesEnabled } = useSignaturePreference() const { triggerPortfolioRefresh } = usePortfolioRefresh() + const { isSpyMode, effectiveAddress } = useEffectiveAddress() + const { chainId: browsedChainId } = useEulerAddresses() - const prepare = async (intents: readonly OperationIntent[], options: PrepareReviewedExecutionOptions): Promise => { + const prepareForMode = async ( + intents: readonly OperationIntent[], + options: PrepareReviewedExecutionOptions, + readOnly: boolean, + ): Promise => { validateIntentSet(intents) const publisher = options.generation ?? new GenerationPublisher() const cartGeneration = options.cartGeneration ?? publisher.advance() if (options.cartGeneration !== undefined) publisher.assertCurrent(cartGeneration) - const captured = await captureWalletBinding(config, signaturesEnabled.value) - publisher.assertCurrent(cartGeneration) const requirements = collectPlanningRequirements(intents) + const capturePreparationBinding = async (): Promise => { + if (!readOnly) return captureWalletBinding(config, signaturesEnabled.value) + const currentAccount = effectiveAddress.value + const currentChainId = browsedChainId.value + if (!isSpyMode.value || !currentAccount || !currentChainId) { + throw new Error('Spy-mode review context is unavailable') + } + return createReadOnlyWalletBinding({ + account: getAddress(currentAccount), + chainId: currentChainId, + subAccounts: requirements.accounts, + }) + } + const captured = await capturePreparationBinding() + publisher.assertCurrent(cartGeneration) if (requirements.chainId !== captured.chainId || requirements.owner !== captured.account) throw new Error('Intent context does not match the connected wallet') const wallet: WalletBinding = { ...captured, subAccounts: requirements.accounts } const assertPreparationContext = async () => { publisher.assertCurrent(cartGeneration) - const current = await captureWalletBinding(config, signaturesEnabled.value) + const current = await capturePreparationBinding() publisher.assertCurrent(cartGeneration) assertExactWalletBinding(wallet, { ...current, subAccounts: wallet.subAccounts }) } @@ -427,11 +476,8 @@ export const useReviewedExecution = () => { after: migrationAfter, assertContext: assertPreparationContext, }) - executions.set(execution.reviewId, execution) - runtimePluginPlans.set(execution.reviewId, pluginPlans) - reviewGenerations.set(execution.reviewId, { publisher, generation: cartGeneration }) const previewPlan = pluginPlans.previewPlan as unknown as TransactionPlan - return { + const preparedReview: PreparedExecutionReview = { execution, previewPlan, prepared: { @@ -443,8 +489,23 @@ export const useReviewedExecution = () => { unlimitedApproval: false, }, } + if (readOnly) return { ...preparedReview, readOnly: true } + executions.set(execution.reviewId, execution) + runtimePluginPlans.set(execution.reviewId, pluginPlans) + reviewGenerations.set(execution.reviewId, { publisher, generation: cartGeneration }) + return preparedReview } + const prepare = ( + intents: readonly OperationIntent[], + options: PrepareReviewedExecutionOptions, + ): Promise => prepareForMode(intents, options, false) + + const prepareReadOnly = ( + intents: readonly OperationIntent[], + options: PrepareReviewedExecutionOptions, + ): Promise => prepareForMode(intents, options, true) + const getReviewedExecution = (reviewId: Hash) => executions.get(reviewId) /** @@ -684,6 +745,7 @@ export const useReviewedExecution = () => { return { prepare, + prepareReadOnly, compilePreview, compilePreviewForSimulation, accept, diff --git a/composables/useTxBatch.ts b/composables/useTxBatch.ts index f74627e78..9dab0810d 100644 --- a/composables/useTxBatch.ts +++ b/composables/useTxBatch.ts @@ -259,6 +259,7 @@ let batchExecutionPreparation: { generation: number intentSetHash: `0x${string}` presentationDigest: `0x${string}` + readOnly: boolean promise: Promise } | undefined @@ -1680,7 +1681,7 @@ export const useTxBatch = () => { const { compilePreviewForSimulation } = executionService const { scheduleExternalMigrationRefreshes } = useExternalMigrationRefresh() const { chainId: wagmiChainId } = useWagmi() - const { effectiveAddress } = useEffectiveAddress() + const { effectiveAddress, isSpyMode } = useEffectiveAddress() const { chainId: addressesChainId } = useEulerAddresses() const owner = computed( @@ -2352,19 +2353,22 @@ export const useTxBatch = () => { const presentationInputs = batchPresentationInputs() const intentSetHash = intentSetDigest(intents) const presentationDigest = reviewPresentationCacheDigest('batch', presentationInputs) + const readOnly = isSpyMode.value if (batchExecutionPreparation && batchExecutionPreparation.generation === cartGeneration && batchExecutionPreparation.intentSetHash === intentSetHash - && batchExecutionPreparation.presentationDigest === presentationDigest) { + && batchExecutionPreparation.presentationDigest === presentationDigest + && batchExecutionPreparation.readOnly === readOnly) { return batchExecutionPreparation.promise } - const promise = executionService.prepare(intents, { + const prepare = readOnly ? executionService.prepareReadOnly : executionService.prepare + const promise = prepare(intents, { presentationKind: 'batch', presentationInputs, generation: batchGenerationPublisher, cartGeneration, }) - batchExecutionPreparation = { generation: cartGeneration, intentSetHash, presentationDigest, promise } + batchExecutionPreparation = { generation: cartGeneration, intentSetHash, presentationDigest, readOnly, promise } void promise.catch(() => { if (batchExecutionPreparation?.promise === promise) batchExecutionPreparation = undefined }) diff --git a/entities/oracle-providers.ts b/entities/oracle-providers.ts index 1183d5baa..dcedf9208 100644 --- a/entities/oracle-providers.ts +++ b/entities/oracle-providers.ts @@ -1,53 +1,58 @@ -const ORACLE_PROVIDER_LOGOS: Record = { +import { DEFAULT_V3_API_URL } from '~/utils/api-url-env' + +const ORACLE_PROVIDER_IMAGE_BASE_URL = `${DEFAULT_V3_API_URL}/v3/images/oracle-providers` + +const ORACLE_PROVIDER_IMAGE_KEYS: Record = { // API provider names - 'API3': '/oracles/api3.svg', - 'Chainlink': '/oracles/chainlink.svg', - 'Chronicle': '/oracles/chronicle.svg', - 'eOracle': '/oracles/eoracle.svg', - 'ERC4626Vault': '/oracles/erc4626.svg', - 'Idle': '/oracles/idle.svg', - 'Lido': '/oracles/lido.svg', - 'Mev': '/oracles/mev.svg', - 'Midas': '/oracles/midas.svg', - 'Pendle': '/oracles/pendle.svg', - 'Poppie': '/oracles/poppie.svg', - 'Pyth': '/oracles/pyth.svg', - 'Redstone': '/oracles/redstone.svg', - 'RedStone': '/oracles/redstone.svg', - 'Resolv': '/oracles/resolv.svg', - 'FixedRate': '/oracles/fixed-rate.svg', - 'Fixed Rate': '/oracles/fixed-rate.svg', - 'RateProvider': '/oracles/rate-provider.svg', - 'Rate Provider': '/oracles/rate-provider.svg', + 'API3': 'api3', + 'Chainlink': 'chainlink', + 'Chronicle': 'chronicle', + 'eOracle': 'eoracle', + 'ERC4626Vault': 'erc4626', + 'Idle': 'idle', + 'Lido': 'lido', + 'Mev': 'mev', + 'Midas': 'midas', + 'Pendle': 'pendle', + 'Poppie': 'poppie', + 'Pyth': 'pyth', + 'Redstone': 'redstone', + 'RedStone': 'redstone', + 'Resolv': 'resolv', + 'FixedRate': 'fixed-rate', + 'Fixed Rate': 'fixed-rate', + 'RateProvider': 'rate-provider', + 'Rate Provider': 'rate-provider', + 'Uniswap V3': 'uniswap-v3', // Oracle tree adapter names (fallback when no API provider metadata) - 'ChainlinkOracle': '/oracles/chainlink.svg', - 'ChainlinkInfrequentOracle': '/oracles/chainlink.svg', - 'PythOracle': '/oracles/pyth.svg', - 'ChronicleOracle': '/oracles/chronicle.svg', - 'RedstoneClassicOracle': '/oracles/redstone.svg', - 'RedstoneCoreOracle': '/oracles/redstone.svg', - 'RedStonePull': '/oracles/redstone.svg', - 'PendleOracle': '/oracles/pendle.svg', - 'PendleUniversalOracle': '/oracles/pendle.svg', - 'LidoFundamental': '/oracles/lido.svg', - 'Lido Fundamental': '/oracles/lido.svg', - 'MEVCapital': '/oracles/mev.svg', - 'MEVLinearDiscount': '/oracles/mev.svg', - 'FixedRateOracle': '/oracles/fixed-rate.svg', - 'RateProviderOracle': '/oracles/rate-provider.svg', + 'ChainlinkOracle': 'chainlink', + 'ChainlinkInfrequentOracle': 'chainlink', + 'PythOracle': 'pyth', + 'ChronicleOracle': 'chronicle', + 'RedstoneClassicOracle': 'redstone', + 'RedstoneCoreOracle': 'redstone', + 'RedStonePull': 'redstone', + 'PendleOracle': 'pendle', + 'PendleUniversalOracle': 'pendle', + 'LidoFundamental': 'lido', + 'Lido Fundamental': 'lido', + 'MEVCapital': 'mev', + 'MEVLinearDiscount': 'mev', + 'FixedRateOracle': 'fixed-rate', + 'RateProviderOracle': 'rate-provider', + 'UniswapV3Oracle': 'uniswap-v3', +} + +const resolveOracleProviderImage = (identifier: string | undefined): string | undefined => { + if (!identifier || !Object.hasOwn(ORACLE_PROVIDER_IMAGE_KEYS, identifier)) return undefined + return `${ORACLE_PROVIDER_IMAGE_BASE_URL}/${ORACLE_PROVIDER_IMAGE_KEYS[identifier]}` } export const getOracleProviderLogo = (provider?: string, adapterName?: string): string | undefined => { // When provider is known, only use its logo — never fall through to adapter name // This prevents e.g. Midas (using ChainlinkOracle) from showing Chainlink's logo if (provider) { - if (Object.hasOwn(ORACLE_PROVIDER_LOGOS, provider)) { - return ORACLE_PROVIDER_LOGOS[provider] - } - return undefined - } - if (adapterName && Object.hasOwn(ORACLE_PROVIDER_LOGOS, adapterName)) { - return ORACLE_PROVIDER_LOGOS[adapterName] + return resolveOracleProviderImage(provider) } - return undefined + return resolveOracleProviderImage(adapterName) } diff --git a/features/reviewed-execution/inventory/registry.ts b/features/reviewed-execution/inventory/registry.ts index de7e801b9..18fc31c56 100644 --- a/features/reviewed-execution/inventory/registry.ts +++ b/features/reviewed-execution/inventory/registry.ts @@ -128,6 +128,7 @@ export const REVIEW_SOURCE_INVENTORY: readonly SourceCountInventoryRow[] = [ { source: 'composables/repay/useSavingsRepay.ts', expectedOccurrences: 1 }, { source: 'composables/repay/useWalletRepay.ts', expectedOccurrences: 1 }, { source: 'composables/repay/useWalletSwapRepay.ts', expectedOccurrences: 1 }, + { source: 'composables/useExecutionReview.ts', expectedOccurrences: 1 }, { source: 'composables/useSwapPageLogic.ts', expectedOccurrences: 1 }, { source: 'pages/earn/[vault]/[subAccount]/withdraw.vue', expectedOccurrences: 1 }, { source: 'pages/earn/[vault]/index.vue', expectedOccurrences: 1 }, diff --git a/nuxt.config.ts b/nuxt.config.ts index 918fd6271..826785486 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -273,13 +273,6 @@ export default defineNuxtConfig({ 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', }, }, - '/oracles/**': { - headers: { - 'Cache-Control': 'public, max-age=86400', - 'CDN-Cache-Control': 'public, max-age=604800', - 'Cloudflare-CDN-Cache-Control': 'public, max-age=604800', - }, - }, '/favicons/**': { headers: { 'Cache-Control': 'public, max-age=86400', 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/pentesting/Euler Penetration Testing Report - 2026-04-20.pdf b/pentesting/Euler Penetration Testing Report - 2026-04-20.pdf new file mode 100644 index 000000000..81f26147e Binary files /dev/null and b/pentesting/Euler Penetration Testing Report - 2026-04-20.pdf differ diff --git a/pentesting/Euler Penetration Testing Report - Phase 2 - 2026-05-23.pdf b/pentesting/Euler Penetration Testing Report - Phase 2 - 2026-05-23.pdf new file mode 100644 index 000000000..867bd2cc0 Binary files /dev/null and b/pentesting/Euler Penetration Testing Report - Phase 2 - 2026-05-23.pdf differ diff --git a/public/oracles/api3.svg b/public/oracles/api3.svg deleted file mode 100644 index 3a97a7e8c..000000000 --- a/public/oracles/api3.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/public/oracles/chainlink.svg b/public/oracles/chainlink.svg deleted file mode 100644 index bf4cd5374..000000000 --- a/public/oracles/chainlink.svg +++ /dev/null @@ -1 +0,0 @@ -Asset 1 \ No newline at end of file diff --git a/public/oracles/chronicle.svg b/public/oracles/chronicle.svg deleted file mode 100644 index b14dda605..000000000 --- a/public/oracles/chronicle.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/oracles/eoracle.svg b/public/oracles/eoracle.svg deleted file mode 100644 index 37d1d70df..000000000 --- a/public/oracles/eoracle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/public/oracles/erc4626.svg b/public/oracles/erc4626.svg deleted file mode 100644 index af3dc3849..000000000 --- a/public/oracles/erc4626.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/oracles/fixed-rate.svg b/public/oracles/fixed-rate.svg deleted file mode 100644 index 313ed3324..000000000 --- a/public/oracles/fixed-rate.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/oracles/idle.svg b/public/oracles/idle.svg deleted file mode 100644 index 2749588d5..000000000 --- a/public/oracles/idle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/oracles/lido.svg b/public/oracles/lido.svg deleted file mode 100644 index 1fb818902..000000000 --- a/public/oracles/lido.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/oracles/mev.svg b/public/oracles/mev.svg deleted file mode 100644 index d7b8de242..000000000 --- a/public/oracles/mev.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/oracles/midas.svg b/public/oracles/midas.svg deleted file mode 100644 index d22bc58b6..000000000 --- a/public/oracles/midas.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/public/oracles/pendle.svg b/public/oracles/pendle.svg deleted file mode 100644 index 32b5b405b..000000000 --- a/public/oracles/pendle.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/public/oracles/poppie.svg b/public/oracles/poppie.svg deleted file mode 100644 index 3a4fc7905..000000000 --- a/public/oracles/poppie.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/oracles/pyth.svg b/public/oracles/pyth.svg deleted file mode 100644 index 332619e16..000000000 --- a/public/oracles/pyth.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - diff --git a/public/oracles/rate-provider.svg b/public/oracles/rate-provider.svg deleted file mode 100644 index 71d33ce22..000000000 --- a/public/oracles/rate-provider.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/public/oracles/redstone.svg b/public/oracles/redstone.svg deleted file mode 100644 index 3383a1833..000000000 --- a/public/oracles/redstone.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/public/oracles/resolv.svg b/public/oracles/resolv.svg deleted file mode 100644 index 53c7f7da9..000000000 --- a/public/oracles/resolv.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/tests/composables/useCollateralSwapRepay.test.ts b/tests/composables/useCollateralSwapRepay.test.ts index ac193d0f1..031666eed 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'), })) @@ -164,6 +189,7 @@ vi.mock('~/composables/useSwapQuotesParallel', () => ({ }) return { sortedQuoteCards: ref([]), + selectedQuoteCard: ref(null), selectedProvider: ref(null), selectedQuote, effectiveQuote, @@ -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, @@ -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', () => ({ @@ -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), @@ -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 | 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, + 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], + })) + }) }) diff --git a/tests/composables/useCrossPositionRepayCollateralOptions.test.ts b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts new file mode 100644 index 000000000..a8372e25e --- /dev/null +++ b/tests/composables/useCrossPositionRepayCollateralOptions.test.ts @@ -0,0 +1,137 @@ +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, 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 +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, + })]) + }) + + 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() + }) +}) diff --git a/tests/composables/useExecutionReview.test.ts b/tests/composables/useExecutionReview.test.ts index 7548397fe..3179e2fa3 100644 --- a/tests/composables/useExecutionReview.test.ts +++ b/tests/composables/useExecutionReview.test.ts @@ -3,9 +3,17 @@ import type { Hash, StateOverride } from 'viem' import type { TransactionPlanPrepared } from '@eulerxyz/euler-v2-sdk' import { useExecutionReview } from '~/composables/useExecutionReview' -const { modalOpen } = vi.hoisted(() => ({ modalOpen: vi.fn() })) +const { modalOpen, operationModal, reviewedOperationModal, isSpyMode } = vi.hoisted(() => ({ + modalOpen: vi.fn(), + operationModal: { name: 'OperationReviewModal' }, + reviewedOperationModal: { name: 'ReviewedOperationModal' }, + isSpyMode: { value: false }, +})) -vi.mock('#components', () => ({ ReviewedOperationModal: {} })) +vi.mock('#components', () => ({ + OperationReviewModal: operationModal, + ReviewedOperationModal: reviewedOperationModal, +})) vi.mock('~/components/ui/composables/useModal', () => ({ useModal: () => ({ open: modalOpen }), })) @@ -16,6 +24,8 @@ const reviewDigest = `0x${'2'.repeat(64)}` as Hash describe('useExecutionReview', () => { beforeEach(() => { modalOpen.mockReset() + isSpyMode.value = false + vi.stubGlobal('useEffectiveAddress', () => ({ isSpyMode })) }) afterEach(() => { @@ -60,4 +70,66 @@ describe('useExecutionReview', () => { tenderlyStateOverrides, }) }) + + it('opens a non-executable prepared preview in spy mode', async () => { + isSpyMode.value = true + const readOnlyPrepared = { chainId: 1, plan: [{ type: 'preview' }] } as unknown as TransactionPlanPrepared + const requests = [{ + requestId: `0x${'3'.repeat(64)}`, + chainId: 1, + from: '0x1000000000000000000000000000000000000000', + to: '0x2000000000000000000000000000000000000000', + data: '0x1234', + value: 0n, + effectIds: [], + phase: 'core', + }] + const prepare = vi.fn() + const prepareReadOnly = vi.fn(async () => ({ + execution: { + reviewId, + reviewDigest, + requestSet: { + wallet: { + account: '0x1000000000000000000000000000000000000000', + walletKind: 'eoa', + }, + requests, + signatureSlots: [], + }, + }, + prepared: readOnlyPrepared, + readOnly: true, + })) + vi.stubGlobal('useReviewedExecution', () => ({ prepare, prepareReadOnly })) + const review = { + asset: { address: '0x2000000000000000000000000000000000000000', symbol: 'USDC', decimals: 6 }, + amount: '1', + type: 'repay', + } + + await useExecutionReview().open([], { + presentationKind: 'repay', + review, + }) + + expect(prepare).not.toHaveBeenCalled() + expect(prepareReadOnly).toHaveBeenCalledWith([], { + presentationKind: 'repay', + presentationInputs: review, + }) + expect(modalOpen).toHaveBeenCalledWith(operationModal, { + props: expect.objectContaining({ + ...review, + prepared: readOnlyPrepared, + calldataPrepared: readOnlyPrepared, + tenderlyPrepared: readOnlyPrepared, + reviewedAccount: '0x1000000000000000000000000000000000000000', + reviewedWalletKind: 'eoa', + reviewedRequests: requests, + reviewedSignatureSlots: [], + readOnly: true, + }), + }) + }) }) diff --git a/tests/composables/useTxBatch.test.ts b/tests/composables/useTxBatch.test.ts index 9ccac12a0..4cd5f5c90 100644 --- a/tests/composables/useTxBatch.test.ts +++ b/tests/composables/useTxBatch.test.ts @@ -56,6 +56,7 @@ const executionMocks = { return { reviewedPlan: plan, plan } }), prepare: vi.fn(async () => { throw new Error('authoritative preparation not configured in batch unit test') }), + prepareReadOnly: vi.fn(async () => { throw new Error('read-only preparation not configured in batch unit test') }), } const scheduleExternalMigrationRefreshes = vi.fn() const position = (account: Address, shares: bigint) => ({ @@ -312,6 +313,7 @@ beforeEach(() => { executionMocks.compilePreview.mockClear() executionMocks.compilePreviewForSimulation.mockClear() executionMocks.prepare.mockClear() + executionMocks.prepareReadOnly.mockClear() scheduleExternalMigrationRefreshes.mockReset() testIntentPlans.clear() testIntentSequence = 0 @@ -1478,6 +1480,31 @@ describe('useTxBatch execution errors', () => { expect(executionMocks.prepare).toHaveBeenCalledOnce() }) + it('warms and adopts read-only multi-operation batch preparation in spy mode', async () => { + const spyMode = ref(true) + vi.stubGlobal('useEffectiveAddress', () => ({ + address: ref(undefined), + isConnected: ref(false), + isSpyMode: spyMode, + spyAddress: ref(owner), + effectiveAddress: ref(owner), + })) + const batch = useTxBatch() + const firstIntent = intentFor([] as TransactionPlan, [subAccount]) + const warmed = { execution: { reviewId: '0x01' }, previewPlan: [], prepared: {}, readOnly: true } + executionMocks.prepareReadOnly.mockResolvedValue(warmed as never) + + await batch.addEntry({ intent: firstIntent, label: 'Repay USDC', subAccount, review: { type: 'repay' } }) + await vi.waitFor(() => expect(executionMocks.prepareReadOnly).toHaveBeenCalledOnce()) + const secondIntent = intentFor([] as TransactionPlan, [subAccount]) + await batch.addEntry({ intent: secondIntent, label: 'Repay RLUSD', subAccount, review: { type: 'repay' } }) + await vi.waitFor(() => expect(executionMocks.prepareReadOnly).toHaveBeenCalledTimes(2)) + + await expect(batch.prepareBatchExecutionReview()).resolves.toBe(warmed) + expect(executionMocks.prepare).not.toHaveBeenCalled() + expect(executionMocks.prepareReadOnly).toHaveBeenCalledTimes(2) + }) + it('clears failed execution messages when the batch is cleared', () => { const batch = useTxBatch() batch.execError.value = 'Execution reverted.' diff --git a/tests/entities/oracle-providers.test.ts b/tests/entities/oracle-providers.test.ts new file mode 100644 index 000000000..ae65e9041 --- /dev/null +++ b/tests/entities/oracle-providers.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { getOracleProviderLogo } from '~/entities/oracle-providers' + +const imageUrl = (key: string) => `https://v3.euler.finance/v3/images/oracle-providers/${key}` + +describe('getOracleProviderLogo', () => { + it('resolves API provider names through the V3 managed-image namespace', () => { + expect(getOracleProviderLogo('Chainlink')).toBe(imageUrl('chainlink')) + expect(getOracleProviderLogo('Uniswap V3')).toBe(imageUrl('uniswap-v3')) + }) + + it('resolves adapter names only when provider metadata is absent', () => { + expect(getOracleProviderLogo(undefined, 'UniswapV3Oracle')).toBe(imageUrl('uniswap-v3')) + expect(getOracleProviderLogo('Midas', 'ChainlinkOracle')).toBe(imageUrl('midas')) + }) + + it('does not infer a logo for an unknown provider', () => { + expect(getOracleProviderLogo('Unknown provider', 'ChainlinkOracle')).toBeUndefined() + }) +}) 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/tests/reviewed-execution/reviewed-execution.test.ts b/tests/reviewed-execution/reviewed-execution.test.ts index 85459b1b1..3fa7c1733 100644 --- a/tests/reviewed-execution/reviewed-execution.test.ts +++ b/tests/reviewed-execution/reviewed-execution.test.ts @@ -12,6 +12,7 @@ import { PreparationCache, type PreparationCacheIdentity } from '~/features/revi import { assertPolicyVersionsMatch, buildReviewedPolicy, collectPolicyRequirements, collectPolicySubjects, type PolicyResultInput } from '~/features/reviewed-execution/policy/engine' import { buildReviewedSimulation, validateSimulationCoverage } from '~/features/reviewed-execution/simulation/coverage' import { createOperationIntent } from '~/features/reviewed-execution/domain/factory' +import { createReadOnlyWalletBinding } from '~/composables/useReviewedExecution' import { makeSwapQuote } from './swap-quote.test-fixture' const ACCOUNT = getAddress('0x1000000000000000000000000000000000000000') @@ -61,6 +62,29 @@ const policyResultsFor = (requestSet: ReturnType collectPolicyRequirements(requestSet).map(requirement => ({ ...requirement, result: allowed() })) describe('reviewed execution semantic kernel', () => { + it('uses a deterministic approval-only wallet binding for spy previews', () => { + const binding = createReadOnlyWalletBinding({ + account: ACCOUNT, + chainId: 1, + subAccounts: [ACCOUNT, VAULT], + }) + + expect(binding).toMatchObject({ + chainId: 1, + account: ACCOUNT, + subAccounts: [ACCOUNT, VAULT], + connectorId: 'spy-mode-read-only', + walletKind: 'eoa', + classificationVersion: 'spy-mode-read-only-v1', + approvalMode: 'approve', + }) + expect(binding.connectorSessionId).toBe(createReadOnlyWalletBinding({ + account: ACCOUNT, + chainId: 1, + subAccounts: [ACCOUNT], + }).connectorSessionId) + }) + it('rejects unbounded variable intents and mixed contexts', () => { expect(() => validateIntentSet([{ ...intent, constraints: [] }])).toThrow(/no bounded outcome/) expect(() => validateIntentSet([intent, { ...intent, intentId: 'intent-2', account: VAULT }])).toThrow(/mixes wallet/) diff --git a/tests/utils/oracle-adapter-views.test.ts b/tests/utils/oracle-adapter-views.test.ts index 88c458909..64b12cf8b 100644 --- a/tests/utils/oracle-adapter-views.test.ts +++ b/tests/utils/oracle-adapter-views.test.ts @@ -39,7 +39,7 @@ describe('buildOracleAdapterView', () => { expect(view.name).toBe('Chainlink WETH/USD') expect(view.isCustomAdapter).toBe(false) expect(view.methodology).toBe('Market price') - expect(view.logo).toBe('/oracles/chainlink.svg') + expect(view.logo).toBe('https://v3.euler.finance/v3/images/oracle-providers/chainlink') expect(view.label).toEqual({ primary: 'Chainlink WETH/USD', suffix: '(Primary)' }) expect(view.checksStatus).toBe('positive') }) 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