diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index 91b25e08..91546bc7 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -70,10 +70,47 @@ export async function planMint({ args: [token, equityInCollateralAsset], }) - // This price is adding the NAV diff from spot on top of the share slippage - const minShares = applySlippageFloor(routerPreview.shares, shareSlippageBps) + const flashLoanAmountInitial = routerPreview.debt + const debtToCollateralQuoteInitial = await quoteDebtToCollateral({ + intent: 'exactIn', + inToken: debtAsset, + outToken: collateralAsset, + amountIn: flashLoanAmountInitial, + slippageBps: swapSlippageBps, + }) + + const previewDepositInitial = await publicClient.readContract({ + address: getContractAddresses(chainId).leverageManagerV2 as Address, + abi: leverageManagerV2Abi, + functionName: 'previewDeposit', + args: [token, equityInCollateralAsset + debtToCollateralQuoteInitial.out], + }) + + const exchangeRateScale = + 10n ** + BigInt( + Math.max( + leverageTokenConfig.debtAsset.decimals, + leverageTokenConfig.collateralAsset.decimals, + ), + ) + const collateralToDebtRateFromQuote = + debtToCollateralQuoteInitial.out > 0n + ? (flashLoanAmountInitial * exchangeRateScale) / debtToCollateralQuoteInitial.out + : 0n + const collateralToDebtRateFromPreviewDeposit = + (previewDepositInitial.debt * exchangeRateScale) / + (equityInCollateralAsset + debtToCollateralQuoteInitial.out) - const flashLoanAmount = applySlippageFloor(routerPreview.debt, flashLoanAdjustmentBps) + const flashLoanAmountUnadjusted = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset, + collateralToDebtRateFromQuote, + collateralToDebtRateFromPreviewDeposit, + exchangeRateScale, + flashLoanAmountInitial, + previewDepositDebtInitialSample: previewDepositInitial.debt, + }) + const flashLoanAmount = applySlippageFloor(flashLoanAmountUnadjusted, flashLoanAdjustmentBps) const debtToCollateralQuote = await quoteDebtToCollateral({ intent: 'exactIn', @@ -83,6 +120,21 @@ export async function planMint({ slippageBps: swapSlippageBps, }) + if (debtToCollateralQuote.out <= 0n) { + captureMintPlanError({ + errorString: `Debt to collateral quote output ${debtToCollateralQuote.out} is less than or equal to 0`, + shareSlippageBps, + swapSlippageBps, + flashLoanAdjustmentBps, + routerPreview, + debtToCollateralQuote, + flashLoanAmount, + }) + throw new Error( + `Insufficient DEX liquidity to mint. Try minting a smaller amount of Leverage Tokens.`, + ) + } + const [managerPreview, managerMin] = await publicClient.multicall({ allowFailure: false, contracts: [ @@ -101,6 +153,8 @@ export async function planMint({ ], }) + const minShares = applySlippageFloor(managerPreview.shares, shareSlippageBps) + if (managerPreview.debt < flashLoanAmount) { captureMintPlanError({ errorString: `Manager previewed debt ${managerPreview.debt} is less than flash loan amount ${flashLoanAmount}.`, @@ -183,3 +237,75 @@ export async function planMint({ quoteSourceId: debtToCollateralQuote.quoteSourceId, } } + +export function solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset, + collateralToDebtRateFromQuote, + collateralToDebtRateFromPreviewDeposit, + exchangeRateScale, + flashLoanAmountInitial, + previewDepositDebtInitialSample, +}: { + equityInCollateralAsset: bigint + collateralToDebtRateFromQuote: bigint + collateralToDebtRateFromPreviewDeposit: bigint + exchangeRateScale: bigint + flashLoanAmountInitial: bigint + previewDepositDebtInitialSample: bigint +}): bigint { + if (collateralToDebtRateFromQuote <= collateralToDebtRateFromPreviewDeposit) { + // Under the linearized model, quoteRate <= previewDepositDebtRate means there is no + // finite upper bound for debt >= flash-loan. Use a sampled point that is + // already observed via exact quote and previewDeposit. + return previewDepositDebtInitialSample >= flashLoanAmountInitial + ? flashLoanAmountInitial + : previewDepositDebtInitialSample + } + + // Derive a bound for flash-loan debt F such that predicted previewDeposit debt covers repayment. + // + // Definitions (all in base units): + // - F = flash-loan debt amount (debt units) + // - E = equityInCollateralAsset (collateral units) + // - q = collateralToDebtRateFromQuote / exchangeRateScale + // where q is debt-per-collateral from the quote sample + // - m = collateralToDebtRateFromPreviewDeposit / exchangeRateScale + // where m is debt-per-collateral from the previewDeposit sample + // + // Approximate relationships: + // - quoteOutCollateral(F) ~= F / q + // - previewDepositDebt(F) ~= m * (E + quoteOutCollateral(F)) + // ~= m * (E + F / q) + // + // Safety condition we want: + // - previewDepositDebt(F) >= F + // + // Solve: + // m * (E + F / q) >= F + // mE + (m/q)F >= F + // mE >= F * (1 - m/q) + // F <= mE / (1 - m/q) + // F <= (m * q * E) / (q - m) + // + // Since rates are scaled integers: + // - mScaled = m * exchangeRateScale + // - qScaled = q * exchangeRateScale + // Substituting and simplifying gives: + // F <= (mScaled * qScaled * E) / (exchangeRateScale * (qScaled - mScaled)) + const numerator = + collateralToDebtRateFromPreviewDeposit * collateralToDebtRateFromQuote * equityInCollateralAsset + const denominator = + exchangeRateScale * (collateralToDebtRateFromQuote - collateralToDebtRateFromPreviewDeposit) + + const maxFlashLoanSatisfyingInequality = denominator > 0n ? numerator / denominator : 0n + + // Defensive fallback: if the inequality-derived bound is zero/negative + // (e.g. due to rounding or degenerate sampled rates), use the router + // preview baseline rather than returning an invalid flash-loan amount. + // This value may still work depending on what the flash loan adjustment parameter is set to. + if (maxFlashLoanSatisfyingInequality <= 0n) { + return flashLoanAmountInitial + } + + return maxFlashLoanSatisfyingInequality +} diff --git a/src/features/leverage-tokens/constants.ts b/src/features/leverage-tokens/constants.ts index 0f28ef19..c5dc87dc 100644 --- a/src/features/leverage-tokens/constants.ts +++ b/src/features/leverage-tokens/constants.ts @@ -25,7 +25,7 @@ export const DEFAULT_COLLATERAL_SLIPPAGE_PERCENT_DISPLAY = '0.5' // Preset slippage options (percent strings) shown in the advanced UI export const SHARE_SLIPPAGE_PRESETS_PERCENT_DISPLAY_MINT = ['0.1', '0.5', '1.0'] as const export const SWAP_SLIPPAGE_PRESETS_PERCENT_DISPLAY = ['0.01', '0.05', '0.1'] as const -export const FLASH_LOAN_ADJUSTMENT_PRESETS_PERCENT_DISPLAY = ['0.5', '1.0', '1.5'] as const +export const FLASH_LOAN_ADJUSTMENT_PRESETS_PERCENT_DISPLAY = ['0.1', '0.5', '1.0'] as const export const COLLATERAL_SLIPPAGE_PRESETS_PERCENT_DISPLAY_REDEEM = ['0.1', '0.5', '1.0'] as const export const COLLATERAL_SWAP_ADJUSTMENT_PRESETS_PERCENT_DISPLAY = ['0.01', '0.05', '0.1'] as const diff --git a/src/lib/observability/sentry.ts b/src/lib/observability/sentry.ts index d08b516e..3ea649fc 100644 --- a/src/lib/observability/sentry.ts +++ b/src/lib/observability/sentry.ts @@ -192,10 +192,10 @@ export function captureMintPlanError(params: { debt: bigint } debtToCollateralQuote: Quote - managerPreview: { + managerPreview?: { debt: bigint } - managerMin: { + managerMin?: { debt: bigint shares: bigint } diff --git a/tests/integration_new/helpers/mint.ts b/tests/integration_new/helpers/mint.ts index 80e569b2..76726ab5 100644 --- a/tests/integration_new/helpers/mint.ts +++ b/tests/integration_new/helpers/mint.ts @@ -8,6 +8,11 @@ import type { MintPlan } from '@/domain/mint' import type { DebtToCollateralSwapConfig } from '@/domain/mint/utils/createDebtToCollateralQuote' import type { QuoteFn } from '@/domain/shared/adapters/types' import { useApprovalFlow } from '@/features/leverage-tokens/components/leverage-token-mint-modal' +import { + DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY, + DEFAULT_SLIPPAGE_PERCENT_DISPLAY, + DEFAULT_SWAP_SLIPPAGE_PERCENT_DISPLAY, +} from '@/features/leverage-tokens/constants' import { useDebtToCollateralQuote } from '@/features/leverage-tokens/hooks/mint/useDebtToCollateralQuote' import { useMintPlanPreview } from '@/features/leverage-tokens/hooks/mint/useMintPlanPreview' import { useMintWrite } from '@/features/leverage-tokens/hooks/mint/useMintWrite' @@ -30,22 +35,21 @@ export class MintExecutionSimulationError extends Error { } } -const DEFAULT_START_SHARE_SLIPPAGE_BPS = 100 -const DEFAULT_SHARE_SLIPPAGE_INCREMENT_BPS = 100 +const DEFAULT_FLASH_LOAN_ADJUSTMENT_INCREMENT_BPS = 100 const MAX_ATTEMPTS = 5 export async function testMint({ client, wagmiConfig, leverageTokenConfig, - startShareSlippageBps = DEFAULT_START_SHARE_SLIPPAGE_BPS, - slippageIncrementBps = DEFAULT_SHARE_SLIPPAGE_INCREMENT_BPS, + startFlashLoanAdjustmentBps = Number(DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY) * 100, + flashLoanAdjustmentIncrementBps = DEFAULT_FLASH_LOAN_ADJUSTMENT_INCREMENT_BPS, }: { client: AnvilTestClient wagmiConfig: Config leverageTokenConfig: LeverageTokenConfig - startShareSlippageBps?: number - slippageIncrementBps?: number + startFlashLoanAdjustmentBps?: number + flashLoanAdjustmentIncrementBps?: number }) { const equityInCollateralAsset = leverageTokenConfig.test.mintIntegrationTest.equityInCollateralAsset @@ -74,8 +78,8 @@ export async function testMint({ wagmiConfig, leverageTokenConfig, equityInCollateralAsset, - startShareSlippageBps, - slippageIncrementBps, + startFlashLoanAdjustmentBps, + flashLoanAdjustmentIncrementBps, }) if (!plan) { @@ -111,17 +115,17 @@ async function mintWithRetries({ wagmiConfig, leverageTokenConfig, equityInCollateralAsset, - startShareSlippageBps, - slippageIncrementBps, + startFlashLoanAdjustmentBps, + flashLoanAdjustmentIncrementBps, }: { client: AnvilTestClient wagmiConfig: Config leverageTokenConfig: LeverageTokenConfig equityInCollateralAsset: bigint - startShareSlippageBps: number - slippageIncrementBps: number + startFlashLoanAdjustmentBps: number + flashLoanAdjustmentIncrementBps: number }): Promise { - let shareSlippageBps = startShareSlippageBps + let flashLoanAdjustmentBps = startFlashLoanAdjustmentBps for (let i = 0; i < MAX_ATTEMPTS; i++) { try { @@ -130,9 +134,9 @@ async function mintWithRetries({ wagmiConfig, leverageTokenConfig, equityInCollateralAsset, - shareSlippageBps, - swapSlippageBps: 1, - flashLoanAdjustmentBps: shareSlippageBps, // Setting to the same value as share slippage works fairly consistently + shareSlippageBps: Number(DEFAULT_SLIPPAGE_PERCENT_DISPLAY) * 100, + swapSlippageBps: Number(DEFAULT_SWAP_SLIPPAGE_PERCENT_DISPLAY) * 100, + flashLoanAdjustmentBps, }) return plan @@ -145,8 +149,8 @@ async function mintWithRetries({ .includes('try increasing the flash loan adjustment parameter')) if (isRetryableError && i < MAX_ATTEMPTS - 1) { - shareSlippageBps += slippageIncrementBps - console.log(`Retrying mint with share slippage bps: ${shareSlippageBps}`) + flashLoanAdjustmentBps += flashLoanAdjustmentIncrementBps + console.log(`Retrying mint with flash loan adjustment bps: ${flashLoanAdjustmentBps}`) continue } diff --git a/tests/unit/domain/mint/plan.v2.spec.ts b/tests/unit/domain/mint/plan.v2.spec.ts index 1664a949..6a982a27 100644 --- a/tests/unit/domain/mint/plan.v2.spec.ts +++ b/tests/unit/domain/mint/plan.v2.spec.ts @@ -1,6 +1,6 @@ import type { Address, Hex, PublicClient } from 'viem' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' -import { planMint } from '@/domain/mint/planner/plan' +import { planMint, solveFlashLoanAmountFromImpliedRates } from '@/domain/mint/planner/plan' import type { LeverageTokenConfig } from '@/features/leverage-tokens/leverageTokens.config' const publicClient = { @@ -54,7 +54,25 @@ describe('planMint', () => { }) it('builds a plan with slippage and approvals', async () => { - const quote = vi.fn(async ({ slippageBps }: { slippageBps: number }) => ({ + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -62,7 +80,7 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (with out) + manager previewDeposit (with minOut) + // previewDeposit (with out) + previewDeposit (with minOut) multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -70,7 +88,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_584n, }, ]) @@ -84,37 +102,162 @@ describe('planMint', () => { quoteDebtToCollateral: quote as any, }) - // flash loan and shares are slippage-adjusted from router preview - expect(plan.flashLoanAmount).toBe(990n) - expect(plan.minShares).toBe(990n) - expect(plan.previewShares).toBe(1_600n) - expect(plan.previewExcessDebt).toBe(310n) // 1300 - 990 - expect(plan.minExcessDebt).toBe(110n) // 1100 - 990 + expect(plan.flashLoanAmount).toBe(839n) + expect(plan.minShares).toBe(1584n) + expect(plan.previewShares).toBe(1600n) + expect(plan.previewExcessDebt).toBe(461n) + expect(plan.minExcessDebt).toBe(261n) expect(plan.calls[0]?.target).toBe(debt) // approval first expect(plan.calls.length).toBeGreaterThanOrEqual(1) - // quote slippage scales with leverage using a 50% factor: - // collateralRatio 3 → leverage 1.5 → (100 * 0.5)/(1.5-1)=100 bps expect(quote).toHaveBeenCalledWith( expect.objectContaining({ slippageBps: 10, - amountIn: 990n, + amountIn: 1000n, + intent: 'exactIn', + inToken: debt, + outToken: collateral, + }), + ) + + expect(quote).toHaveBeenCalledWith( + expect.objectContaining({ + slippageBps: 10, + amountIn: 839n, + intent: 'exactIn', + inToken: debt, + outToken: collateral, + }), + ) + }) + + it('sets initial quote exchange rate to zero when initial debt-to-collateral quote output is zero and continues', async () => { + const quote = vi.fn() + quote.mockResolvedValueOnce({ + out: 0n, + minOut: 0n, + approvalTarget: debt, + calls: [], + slippageBps: 10, + }) + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + quote.mockResolvedValueOnce({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps: 10, + }) + multicall.mockResolvedValueOnce([ + { debt: 1_300n, shares: 1_600n }, + { debt: 1_100n, shares: 1_584n }, + ]) + + const plan = await planMint({ + publicClient, + leverageTokenConfig, + equityInCollateralAsset: 500n, + shareSlippageBps: 100, + swapSlippageBps: 10, + flashLoanAdjustmentBps: 100, + quoteDebtToCollateral: quote as any, + }) + + expect(plan.flashLoanAmount).toBe(940n) + expect(plan.minShares).toBe(1584n) + expect(plan.previewShares).toBe(1600n) + expect(plan.previewExcessDebt).toBe(360n) + expect(plan.minExcessDebt).toBe(160n) + expect(plan.calls[0]?.target).toBe(debt) + expect(plan.calls.length).toBeGreaterThanOrEqual(1) + + expect(quote).toHaveBeenCalledWith( + expect.objectContaining({ + slippageBps: 10, + amountIn: 1000n, intent: 'exactIn', inToken: debt, outToken: collateral, }), ) + expect(quote).toHaveBeenCalledWith( + expect.objectContaining({ + slippageBps: 10, + amountIn: 940n, + intent: 'exactIn', + inToken: debt, + outToken: collateral, + }), + ) + }) + + it('throws when debt-to-collateral quote for flash loan amount returns zero or negative output', async () => { + const quote = vi.fn() + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + quote.mockImplementationOnce(async () => ({ + out: 0n, + minOut: 0n, + approvalTarget: debt, + calls: [], + slippageBps: 10, + })) + + await expect( + planMint({ + publicClient, + leverageTokenConfig, + equityInCollateralAsset: 500n, + shareSlippageBps: 100, + swapSlippageBps: 10, + flashLoanAdjustmentBps: 100, + quoteDebtToCollateral: quote as any, + }), + ).rejects.toThrow( + /Insufficient DEX liquidity to mint. Try minting a smaller amount of Leverage Tokens./i, + ) }) - it('throws when manager previewed debt is below flash loan amount', async () => { - const quote = vi.fn(async () => ({ + it('throws when previewDeposit debt is below flash loan amount', async () => { + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, })) - // manager previewDeposit returns debt below flash loan amount + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // previewDeposit returns debt below flash loan amount multicall.mockResolvedValueOnce([ { debt: 800n, @@ -139,12 +282,31 @@ describe('planMint', () => { ).rejects.toThrow(/Flash loan too large. Try increasing the flash loan adjustment parameter./i) }) - it('throws when manager minimum debt is below flash loan amount', async () => { - const quote = vi.fn(async () => ({ - out: 1_300n, + it('throws when previewDeposit minimum debt is below flash loan amount', async () => { + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, minOut: 1_100n, approvalTarget: debt, calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, })) multicall.mockResolvedValueOnce([ @@ -173,15 +335,34 @@ describe('planMint', () => { ) }) - it('throws when minimum shares from manager are below slippage floor', async () => { - const quote = vi.fn(async () => ({ + it('throws when minimum shares from previewDeposit are below slippage floor', async () => { + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, })) - // manager previewDeposit (minOut path) returns shares below minShares + // previewDeposit (minOut path) returns shares below minShares multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -209,7 +390,10 @@ describe('planMint', () => { }) it('builds a plan with zero flash loan adjustment', async () => { - const quote = vi.fn(async ({ slippageBps }: { slippageBps: number }) => ({ + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -217,7 +401,22 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (with out) + manager previewDeposit (with minOut) + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // previewDeposit (with out) + previewDeposit (with minOut) multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -225,7 +424,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_600n, }, ]) @@ -239,12 +438,11 @@ describe('planMint', () => { quoteDebtToCollateral: quote as any, }) - // flash loan and shares are slippage-adjusted from router preview - expect(plan.flashLoanAmount).toBe(1000n) - expect(plan.minShares).toBe(990n) - expect(plan.previewShares).toBe(1_600n) - expect(plan.previewExcessDebt).toBe(300n) // 1300 - 1000 - expect(plan.minExcessDebt).toBe(100n) // 1100 - 1000 + expect(plan.flashLoanAmount).toBe(848n) + expect(plan.minShares).toBe(1584n) + expect(plan.previewShares).toBe(1600n) + expect(plan.previewExcessDebt).toBe(452n) + expect(plan.minExcessDebt).toBe(252n) expect(plan.calls[0]?.target).toBe(debt) // approval first expect(plan.calls.length).toBeGreaterThanOrEqual(1) @@ -257,10 +455,23 @@ describe('planMint', () => { outToken: collateral, }), ) + + expect(quote).toHaveBeenCalledWith( + expect.objectContaining({ + slippageBps: 10, + amountIn: 848n, + intent: 'exactIn', + inToken: debt, + outToken: collateral, + }), + ) }) it('builds a plan with negative flash loan adjustment', async () => { - const quote = vi.fn(async ({ slippageBps }: { slippageBps: number }) => ({ + const quote = vi.fn() + + // Initial quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -268,7 +479,22 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (with out) + manager previewDeposit (with minOut) + // Initial previewDeposit + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ + out: 1_200n, + minOut: 1_100n, + approvalTarget: debt, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // previewDeposit (with out) + previewDeposit (with minOut) multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -276,7 +502,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_600n, }, ]) @@ -290,19 +516,28 @@ describe('planMint', () => { quoteDebtToCollateral: quote as any, }) - // flash loan and shares are slippage-adjusted from router preview - expect(plan.flashLoanAmount).toBe(1010n) - expect(plan.minShares).toBe(990n) + expect(plan.flashLoanAmount).toBe(856n) + expect(plan.minShares).toBe(1584n) expect(plan.previewShares).toBe(1_600n) - expect(plan.previewExcessDebt).toBe(290n) // 1300 - 1010 - expect(plan.minExcessDebt).toBe(90n) // 1100 - 1010 + expect(plan.previewExcessDebt).toBe(444n) + expect(plan.minExcessDebt).toBe(244n) expect(plan.calls[0]?.target).toBe(debt) // approval first expect(plan.calls.length).toBeGreaterThanOrEqual(1) expect(quote).toHaveBeenCalledWith( expect.objectContaining({ slippageBps: 10, - amountIn: 1010n, + amountIn: 1000n, + intent: 'exactIn', + inToken: debt, + outToken: collateral, + }), + ) + + expect(quote).toHaveBeenCalledWith( + expect.objectContaining({ + slippageBps: 10, + amountIn: 856n, intent: 'exactIn', inToken: debt, outToken: collateral, @@ -338,3 +573,67 @@ describe('planMint', () => { ).rejects.toThrow(/Swap slippage cannot be less than 0.01%/i) }) }) + +describe('solveFlashLoanAmountFromImpliedRates', () => { + it('returns initial flash loan when quote-implied rate is less than previewDeposit-implied rate', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromPreviewDeposit: 9_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + previewDepositDebtInitialSample: 1_100n, + }) + + expect(flashLoanAmount).toBe(1_000n) + }) + + it('returns sampled previewDeposit debt when quote-implied rate is less than previewDeposit-implied rate', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromPreviewDeposit: 9_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + previewDepositDebtInitialSample: 900n, + }) + + expect(flashLoanAmount).toBe(900n) + }) + + it('solves the inequality bound when quote-implied rate is greater than previewDeposit-implied rate', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromPreviewDeposit: 7_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + previewDepositDebtInitialSample: 950n, + }) + + // Formula: + // F_max = (mScaled * qScaled * E) / (scale * (qScaled - mScaled)) + // = (7000 * 8000 * 500) / (10000 * (8000 - 7000)) + // = 28,000,000,000 / 10,000,000 + // = 2800 + expect(flashLoanAmount).toBe(2_800n) + }) + + it('falls back to initial flash loan when computed inequality bound rounds down to zero', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 1n, + collateralToDebtRateFromQuote: 3n, + collateralToDebtRateFromPreviewDeposit: 2n, + exchangeRateScale: 1_000n, + flashLoanAmountInitial: 1_000n, + previewDepositDebtInitialSample: 900n, + }) + + // Formula: + // F_max = (2 * 3 * 1) / (1000 * (3 - 2)) + // = 6 / 1000 + // = 0 (integer division) + // Fallback should return flashLoanAmountInitial. + expect(flashLoanAmount).toBe(1_000n) + }) +})