From b860ae0df536a4b2fbf3825c702c930abd9dd832 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:04:56 +0100 Subject: [PATCH 1/3] fix: count initial collateral in CoW supply cap checks Check the combined deposit and quoted swap output before CoW preparation, using the collateral asset decimals. --- composables/borrow/useMultiplyCowSwap.ts | 12 ++-- tests/composables/useMultiplyCowSwap.test.ts | 63 ++++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 tests/composables/useMultiplyCowSwap.test.ts diff --git a/composables/borrow/useMultiplyCowSwap.ts b/composables/borrow/useMultiplyCowSwap.ts index ce6154f24..f51dc5344 100644 --- a/composables/borrow/useMultiplyCowSwap.ts +++ b/composables/borrow/useMultiplyCowSwap.ts @@ -100,14 +100,15 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { const quote = options.multiplySelectedQuote.value if (!quote) return - // CoW pre-flight: orders can't be simulated, so the multiply form's - // existing errorText already screens most failure modes — but it doesn't - // see the post-swap collateral amount that we're about to push into the - // long vault. Guard against exceeding the supply cap on the long side. + const supplyAmountNano = valueToNano(options.multiplyInputAmount.value || '0', supplyVault.asset.decimals) + + // The multiply form requires the collateral and long vault to match. + // Count both the initial deposit and swap output against its supply cap + // before preparing a CoW order, which skips transaction simulation. const longCap = longVault.caps?.supplyCap if (typeof longCap === 'bigint' && longCap > 0n && longCap < maxUint256) { const buyAmount = BigInt(quote.amountOut || '0') - if (longVault.totalAssets + buyAmount > longCap) { + if (longVault.totalAssets + supplyAmountNano + buyAmount > longCap) { error('Long vault supply cap would be exceeded') return } @@ -152,7 +153,6 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { return } - const supplyAmountNano = valueToNano(options.multiplyInputAmount.value || '0', supplyVault.asset.decimals) const validTo = Math.floor(Date.now() / 1000) + COWSWAP_ORDER_DEADLINE_SECONDS // For the review modal we still want to show CoW order amounts (sell/buy) diff --git a/tests/composables/useMultiplyCowSwap.test.ts b/tests/composables/useMultiplyCowSwap.test.ts new file mode 100644 index 000000000..b4f0f2910 --- /dev/null +++ b/tests/composables/useMultiplyCowSwap.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computed, ref } from 'vue' +import { maxUint256, parseUnits } from 'viem' +import type { EVault, SwapQuote } from '@eulerxyz/euler-v2-sdk' +import { useMultiplyCowSwap } from '~/composables/borrow/useMultiplyCowSwap' +import { valueToNano } from '~/utils/crypto-utils' + +const mocks = vi.hoisted(() => ({ error: vi.fn(), getNewSubAccount: vi.fn() })) +vi.mock('~/components/ui/composables/useToast', () => ({ useToast: () => ({ error: mocks.error }) })) +vi.mock('~/components/ui/composables/useModal', () => ({ useModal: () => ({ close: vi.fn() }) })) +vi.mock('~/composables/useSubAccounts', () => ({ getNewSubAccount: mocks.getNewSubAccount })) +vi.mock('~/utils/moooler-sound', () => ({ prepareMooolerSound: vi.fn(), playMooolerSound: vi.fn() })) +vi.mock('~/composables/cowswap', () => ({ + useCowSwapOpenPositionExecution: () => ({ reset: vi.fn(), status: ref('idle'), orderUid: ref(null) }), + useCowSwapOrderStatus: () => ({ orderStatus: ref(null) }), + openCowSwapReviewModal: vi.fn(), buildApprovalSignSteps: vi.fn(), +})) + +const address = '0x0000000000000000000000000000000000000001' +const subAccount = '0x0000000000000000000000000000000000000002' +const createForm = (deposit: string, output: string, cap: bigint, decimals: number) => { + const vault = { address, asset: { address, decimals }, caps: { supplyCap: cap }, totalAssets: parseUnits('900', decimals) } as unknown as EVault + const quote = { amountOut: parseUnits(output, decimals).toString(), accountIn: subAccount, accountOut: subAccount } as unknown as SwapQuote + const options = { + multiplySupplyVault: computed(() => vault), multiplyLongVault: computed(() => vault), multiplyShortVault: computed(() => vault), + multiplyInputAmount: ref(deposit), multiplyDebtAmountNano: computed(() => 1n), multiplyErrorText: computed(() => null), + multiplySelectedQuote: computed(() => quote), multiplySelectedProvider: computed(() => null), multiplyEffectiveQuote: computed(() => quote), + account: computed(() => undefined), + } as Parameters[0] + return useMultiplyCowSwap(options) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.getNewSubAccount.mockResolvedValue(subAccount) + vi.stubGlobal('valueToNano', valueToNano) + vi.stubGlobal('useWagmi', () => ({ address: ref(address) })) + vi.stubGlobal('useRouter', () => ({})) + vi.stubGlobal('useRpcClient', () => ({ client: ref(null) })) + vi.stubGlobal('useEulerAddresses', () => ({ chainId: ref(1) })) + vi.stubGlobal('useEulerAccount', () => ({ refreshAllPositions: vi.fn() })) +}) +afterEach(() => vi.unstubAllGlobals()) + +describe('CoW multiply supply capacity', () => { + it.each([6, 18])('blocks the combined deposits exceeding remaining capacity (%i decimals)', async (decimals) => { + await createForm('60', '60', parseUnits('1000', decimals), decimals).submitCowSwapMultiply() + expect(mocks.error).toHaveBeenCalledWith('Long vault supply cap would be exceeded') + expect(mocks.getNewSubAccount).not.toHaveBeenCalled() + }) + + it.each(['39', '40'])('allows combined deposits below or at the cap (output %s)', async (output) => { + await createForm('60', output, parseUnits('1000', 6), 6).submitCowSwapMultiply() + expect(mocks.error).not.toHaveBeenCalledWith('Long vault supply cap would be exceeded') + expect(mocks.getNewSubAccount).toHaveBeenCalledOnce() + }) + + it.each([0n, maxUint256])('preserves unlimited cap behavior (%s)', async (cap) => { + await createForm('60', '60', cap, 6).submitCowSwapMultiply() + expect(mocks.error).not.toHaveBeenCalledWith('Long vault supply cap would be exceeded') + expect(mocks.getNewSubAccount).toHaveBeenCalledOnce() + }) +}) From 7aa1826bda1dccfd89925942e48db76df8081c91 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:33:29 +0100 Subject: [PATCH 2/3] fix: preserve captured CoW deposit in review Use the captured deposit amount for approval and Supply rows, with regressions for edits during sub-account and allowance reads. --- composables/borrow/useMultiplyCowSwap.ts | 7 +-- tests/composables/useMultiplyCowSwap.test.ts | 57 +++++++++++++++++--- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/composables/borrow/useMultiplyCowSwap.ts b/composables/borrow/useMultiplyCowSwap.ts index f51dc5344..bb340926b 100644 --- a/composables/borrow/useMultiplyCowSwap.ts +++ b/composables/borrow/useMultiplyCowSwap.ts @@ -100,7 +100,8 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { const quote = options.multiplySelectedQuote.value if (!quote) return - const supplyAmountNano = valueToNano(options.multiplyInputAmount.value || '0', supplyVault.asset.decimals) + const supplyAmount = options.multiplyInputAmount.value + const supplyAmountNano = valueToNano(supplyAmount || '0', supplyVault.asset.decimals) // The multiply form requires the collateral and long vault to match. // Count both the initial deposit and swap output against its supply cap @@ -224,7 +225,7 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { currentAllowance: collateralAllowance, requiredAmount: supplyAmountNano, label: 'Approve for deposit', - assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: options.multiplyInputAmount.value }, + assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: supplyAmount }, startIndex: idx, }) signSteps.push(...collateralApproval.steps) @@ -251,7 +252,7 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { const wrapperSteps: DisplayStep[] = [ { index: wIdx++, label: 'Enable collateral', labelSuffix: collateralVaultName, isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address } }, { index: wIdx++, label: 'Enable controller', labelSuffix: borrowVaultName, isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address } }, - { index: wIdx++, label: 'Supply', isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: options.multiplyInputAmount.value } }, + { index: wIdx++, label: 'Supply', isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: supplyAmount } }, { index: wIdx++, label: 'Borrow', isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address, amount: borrowAmountStr } }, { index: wIdx++, label: 'Swap', isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address, amount: borrowAmountStr }, toAssetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: options.multiplyLongAmount.value } }, { index: wIdx, label: 'Verify min received', isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: swapOutMinAmount } }, diff --git a/tests/composables/useMultiplyCowSwap.test.ts b/tests/composables/useMultiplyCowSwap.test.ts index b4f0f2910..ecfa7aa5c 100644 --- a/tests/composables/useMultiplyCowSwap.test.ts +++ b/tests/composables/useMultiplyCowSwap.test.ts @@ -5,7 +5,7 @@ import type { EVault, SwapQuote } from '@eulerxyz/euler-v2-sdk' import { useMultiplyCowSwap } from '~/composables/borrow/useMultiplyCowSwap' import { valueToNano } from '~/utils/crypto-utils' -const mocks = vi.hoisted(() => ({ error: vi.fn(), getNewSubAccount: vi.fn() })) +const mocks = vi.hoisted(() => ({ error: vi.fn(), getNewSubAccount: vi.fn(), showReview: vi.fn(), approvalSteps: vi.fn(), readContract: vi.fn() })) vi.mock('~/components/ui/composables/useToast', () => ({ useToast: () => ({ error: mocks.error }) })) vi.mock('~/components/ui/composables/useModal', () => ({ useModal: () => ({ close: vi.fn() }) })) vi.mock('~/composables/useSubAccounts', () => ({ getNewSubAccount: mocks.getNewSubAccount })) @@ -13,30 +13,39 @@ vi.mock('~/utils/moooler-sound', () => ({ prepareMooolerSound: vi.fn(), playMooo vi.mock('~/composables/cowswap', () => ({ useCowSwapOpenPositionExecution: () => ({ reset: vi.fn(), status: ref('idle'), orderUid: ref(null) }), useCowSwapOrderStatus: () => ({ orderStatus: ref(null) }), - openCowSwapReviewModal: vi.fn(), buildApprovalSignSteps: vi.fn(), + openCowSwapReviewModal: mocks.showReview, buildApprovalSignSteps: mocks.approvalSteps, +})) + +vi.mock('~/entities/cowswap', async original => ({ + ...await original(), + getCowSwapQuoteOrderAmounts: () => ({ sellAmount: 1n, buyAmount: 1n }), })) const address = '0x0000000000000000000000000000000000000001' const subAccount = '0x0000000000000000000000000000000000000002' const createForm = (deposit: string, output: string, cap: bigint, decimals: number) => { - const vault = { address, asset: { address, decimals }, caps: { supplyCap: cap }, totalAssets: parseUnits('900', decimals) } as unknown as EVault + const vault = { address, asset: { address, decimals }, caps: { supplyCap: cap }, totalShares: parseUnits('900', decimals), totalAssets: parseUnits('900', decimals) } as unknown as EVault const quote = { amountOut: parseUnits(output, decimals).toString(), accountIn: subAccount, accountOut: subAccount } as unknown as SwapQuote const options = { multiplySupplyVault: computed(() => vault), multiplyLongVault: computed(() => vault), multiplyShortVault: computed(() => vault), multiplyInputAmount: ref(deposit), multiplyDebtAmountNano: computed(() => 1n), multiplyErrorText: computed(() => null), multiplySelectedQuote: computed(() => quote), multiplySelectedProvider: computed(() => null), multiplyEffectiveQuote: computed(() => quote), - account: computed(() => undefined), + account: computed(() => ({})), multiplySlippage: ref(0.5), + multiplySupplyProduct: computed(() => ({ name: 'Supply' })), multiplyShortProduct: computed(() => ({ name: 'Borrow' })), + multiplyLongAmount: computed(() => output), multiplyEffectiveQuoteFetchedAt: computed(() => 123), } as Parameters[0] - return useMultiplyCowSwap(options) + return { ...useMultiplyCowSwap(options), input: options.multiplyInputAmount } } beforeEach(() => { vi.clearAllMocks() mocks.getNewSubAccount.mockResolvedValue(subAccount) + mocks.approvalSteps.mockReturnValue({ steps: [], nextIndex: 1 }) + mocks.readContract.mockResolvedValue(0n) vi.stubGlobal('valueToNano', valueToNano) vi.stubGlobal('useWagmi', () => ({ address: ref(address) })) vi.stubGlobal('useRouter', () => ({})) - vi.stubGlobal('useRpcClient', () => ({ client: ref(null) })) + vi.stubGlobal('useRpcClient', () => ({ client: ref({ readContract: mocks.readContract }) })) vi.stubGlobal('useEulerAddresses', () => ({ chainId: ref(1) })) vi.stubGlobal('useEulerAccount', () => ({ refreshAllPositions: vi.fn() })) }) @@ -61,3 +70,39 @@ describe('CoW multiply supply capacity', () => { expect(mocks.getNewSubAccount).toHaveBeenCalledOnce() }) }) + +it.each(['sub-account', 'allowance'])('keeps the captured deposit in review while %s lookup is pending', async (pendingStage) => { + let resolve!: () => void + const gate = new Promise((done) => { + resolve = done + }) + if (pendingStage === 'sub-account') { + mocks.getNewSubAccount.mockImplementationOnce(async () => { + await gate + return subAccount + }) + } + else { + mocks.readContract.mockImplementationOnce(async () => { + await gate + return 0n + }) + } + const form = createForm('60', '20', parseUnits('1000', 6), 6) + const submitted = form.submitCowSwapMultiply() + await vi.waitFor(() => expect(pendingStage === 'sub-account' ? mocks.getNewSubAccount : mocks.readContract).toHaveBeenCalled()) + form.input.value = '1' + resolve() + await submitted + + expect(mocks.showReview).toHaveBeenCalledOnce() + const review = mocks.showReview.mock.calls[0][1] + expect(review.executeParams.collateralAmount).toBe(parseUnits('60', 6)) + expect(review.wrapperSteps).toContainEqual(expect.objectContaining({ + label: 'Supply', assetInfo: expect.objectContaining({ amount: '60' }), + })) + expect(mocks.approvalSteps).toHaveBeenCalledWith(expect.objectContaining({ + label: 'Approve for deposit', requiredAmount: parseUnits('60', 6), + assetInfo: expect.objectContaining({ amount: '60' }), + })) +}) From a2a5c7d0ecfff455b701c0fcd593dac762e71ad9 Mon Sep 17 00:00:00 2001 From: Seranged <80223622+Seranged@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:04:21 +0100 Subject: [PATCH 3/3] fix: derive CoW review output from captured quote Keep the Swap row aligned with the execution quote when live form output clears during asynchronous preparation. --- composables/borrow/useMultiplyCowSwap.ts | 3 ++- tests/composables/useMultiplyCowSwap.test.ts | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/composables/borrow/useMultiplyCowSwap.ts b/composables/borrow/useMultiplyCowSwap.ts index bb340926b..481beff86 100644 --- a/composables/borrow/useMultiplyCowSwap.ts +++ b/composables/borrow/useMultiplyCowSwap.ts @@ -100,6 +100,7 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { const quote = options.multiplySelectedQuote.value if (!quote) return + const swapOutputAmount = trimTrailingZeros(formatUnits(BigInt(quote.amountOut || '0'), Number(longVault.asset.decimals))) const supplyAmount = options.multiplyInputAmount.value const supplyAmountNano = valueToNano(supplyAmount || '0', supplyVault.asset.decimals) @@ -254,7 +255,7 @@ export const useMultiplyCowSwap = (options: UseMultiplyCowSwapOptions) => { { index: wIdx++, label: 'Enable controller', labelSuffix: borrowVaultName, isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address } }, { index: wIdx++, label: 'Supply', isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: supplyAmount } }, { index: wIdx++, label: 'Borrow', isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address, amount: borrowAmountStr } }, - { index: wIdx++, label: 'Swap', isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address, amount: borrowAmountStr }, toAssetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: options.multiplyLongAmount.value } }, + { index: wIdx++, label: 'Swap', isSeparateTx: false, assetInfo: { symbol: borrowAsset.symbol, address: borrowAsset.address, amount: borrowAmountStr }, toAssetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: swapOutputAmount } }, { index: wIdx, label: 'Verify min received', isSeparateTx: false, assetInfo: { symbol: collateralAsset.symbol, address: collateralAsset.address, amount: swapOutMinAmount } }, ] diff --git a/tests/composables/useMultiplyCowSwap.test.ts b/tests/composables/useMultiplyCowSwap.test.ts index ecfa7aa5c..93222f66d 100644 --- a/tests/composables/useMultiplyCowSwap.test.ts +++ b/tests/composables/useMultiplyCowSwap.test.ts @@ -26,15 +26,16 @@ const subAccount = '0x0000000000000000000000000000000000000002' const createForm = (deposit: string, output: string, cap: bigint, decimals: number) => { const vault = { address, asset: { address, decimals }, caps: { supplyCap: cap }, totalShares: parseUnits('900', decimals), totalAssets: parseUnits('900', decimals) } as unknown as EVault const quote = { amountOut: parseUnits(output, decimals).toString(), accountIn: subAccount, accountOut: subAccount } as unknown as SwapQuote + const outputAmount = ref(output) const options = { multiplySupplyVault: computed(() => vault), multiplyLongVault: computed(() => vault), multiplyShortVault: computed(() => vault), multiplyInputAmount: ref(deposit), multiplyDebtAmountNano: computed(() => 1n), multiplyErrorText: computed(() => null), multiplySelectedQuote: computed(() => quote), multiplySelectedProvider: computed(() => null), multiplyEffectiveQuote: computed(() => quote), account: computed(() => ({})), multiplySlippage: ref(0.5), multiplySupplyProduct: computed(() => ({ name: 'Supply' })), multiplyShortProduct: computed(() => ({ name: 'Borrow' })), - multiplyLongAmount: computed(() => output), multiplyEffectiveQuoteFetchedAt: computed(() => 123), + multiplyLongAmount: computed(() => outputAmount.value), multiplyEffectiveQuoteFetchedAt: computed(() => 123), } as Parameters[0] - return { ...useMultiplyCowSwap(options), input: options.multiplyInputAmount } + return { ...useMultiplyCowSwap(options), input: options.multiplyInputAmount, output: outputAmount } } beforeEach(() => { @@ -92,12 +93,17 @@ it.each(['sub-account', 'allowance'])('keeps the captured deposit in review whil const submitted = form.submitCowSwapMultiply() await vi.waitFor(() => expect(pendingStage === 'sub-account' ? mocks.getNewSubAccount : mocks.readContract).toHaveBeenCalled()) form.input.value = '1' + form.output.value = '' resolve() await submitted expect(mocks.showReview).toHaveBeenCalledOnce() const review = mocks.showReview.mock.calls[0][1] expect(review.executeParams.collateralAmount).toBe(parseUnits('60', 6)) + expect(review.executeParams.swapQuote.amountOut).toBe(parseUnits('20', 6).toString()) + expect(review.wrapperSteps).toContainEqual(expect.objectContaining({ + label: 'Swap', toAssetInfo: expect.objectContaining({ amount: '20' }), + })) expect(review.wrapperSteps).toContainEqual(expect.objectContaining({ label: 'Supply', assetInfo: expect.objectContaining({ amount: '60' }), }))