Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions composables/borrow/useMultiplyCowSwap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,17 @@ 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 swapOutputAmount = trimTrailingZeros(formatUnits(BigInt(quote.amountOut || '0'), Number(longVault.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
// 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
}
Expand Down Expand Up @@ -152,7 +155,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)
Expand Down Expand Up @@ -224,7 +226,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)
Expand All @@ -251,9 +253,9 @@ 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: '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 } },
]

Expand Down
114 changes: 114 additions & 0 deletions tests/composables/useMultiplyCowSwap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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(), 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 }))
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: mocks.showReview, buildApprovalSignSteps: mocks.approvalSteps,
}))

vi.mock('~/entities/cowswap', async original => ({
...await original<typeof import('~/entities/cowswap')>(),
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 }, 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(() => outputAmount.value), multiplyEffectiveQuoteFetchedAt: computed(() => 123),
} as Parameters<typeof useMultiplyCowSwap>[0]
return { ...useMultiplyCowSwap(options), input: options.multiplyInputAmount, output: outputAmount }
}

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({ readContract: mocks.readContract }) }))
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()
})
})

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<void>((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'
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' }),
}))
expect(mocks.approvalSteps).toHaveBeenCalledWith(expect.objectContaining({
label: 'Approve for deposit', requiredAmount: parseUnits('60', 6),
assetInfo: expect.objectContaining({ amount: '60' }),
}))
})
Loading