From caf600a3e40a394e7bea1f4e27f9848bf7804ad1 Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 11:27:53 -0500 Subject: [PATCH 1/7] feat: use exchange rates to approximate flash loan amount on mint --- src/domain/mint/planner/plan.ts | 119 +++++++++++++++++++++- src/features/leverage-tokens/constants.ts | 4 +- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index 91b25e08..def86ea2 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -70,10 +70,45 @@ 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 managerPreviewInitial = 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 = + (flashLoanAmountInitial * exchangeRateScale) / debtToCollateralQuoteInitial.out + const collateralToDebtRateFromManager = + (managerPreviewInitial.debt * exchangeRateScale) / + (equityInCollateralAsset + debtToCollateralQuoteInitial.out) - const flashLoanAmount = applySlippageFloor(routerPreview.debt, flashLoanAdjustmentBps) + const flashLoanAmountUnadjusted = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset, + collateralToDebtRateFromQuote, + collateralToDebtRateFromManager, + exchangeRateScale, + flashLoanAmountInitial, + managerDebtAtInitialSample: managerPreviewInitial.debt, + }) + const flashLoanAmount = applySlippageFloor(flashLoanAmountUnadjusted, flashLoanAdjustmentBps) const debtToCollateralQuote = await quoteDebtToCollateral({ intent: 'exactIn', @@ -101,6 +136,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 +220,79 @@ export async function planMint({ quoteSourceId: debtToCollateralQuote.quoteSourceId, } } + +function solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset, + collateralToDebtRateFromQuote, + collateralToDebtRateFromManager, + exchangeRateScale, + flashLoanAmountInitial, + managerDebtAtInitialSample, +}: { + equityInCollateralAsset: bigint + collateralToDebtRateFromQuote: bigint + collateralToDebtRateFromManager: bigint + exchangeRateScale: bigint + flashLoanAmountInitial: bigint + managerDebtAtInitialSample: bigint +}): bigint { + if (collateralToDebtRateFromQuote <= collateralToDebtRateFromManager) { + // Under the linearized model, quoteRate <= managerRate means there is no + // finite upper bound for debt >= flash-loan. Use the sampled point that is + // already observed via exact quote + manager preview. + const sampledFeasibleFlashLoanAmount = + managerDebtAtInitialSample >= flashLoanAmountInitial + ? flashLoanAmountInitial + : managerDebtAtInitialSample + return sampledFeasibleFlashLoanAmount > 0n + ? sampledFeasibleFlashLoanAmount + : flashLoanAmountInitial + } + + // Derive a bound for flash-loan debt F such that predicted manager 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 = collateralToDebtRateFromManager / exchangeRateScale + // where m is debt-per-collateral from the manager sample + // + // Approximate relationships: + // - quoteOutCollateral(F) ~= F / q + // - managerDebt(F) ~= m * (E + quoteOutCollateral(F)) + // ~= m * (E + F / q) + // + // Safety condition we want: + // - managerDebt(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 = + collateralToDebtRateFromManager * collateralToDebtRateFromQuote * equityInCollateralAsset + const denominator = + exchangeRateScale * (collateralToDebtRateFromQuote - collateralToDebtRateFromManager) + + const maxFlashLoanSatisfyingInequality = numerator / denominator + + // 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..ade0b64a 100644 --- a/src/features/leverage-tokens/constants.ts +++ b/src/features/leverage-tokens/constants.ts @@ -17,7 +17,7 @@ export const DEFAULT_SWAP_SLIPPAGE_PERCENT_DISPLAY = '0.01' export const DEFAULT_COLLATERAL_SWAP_ADJUSTMENT_PERCENT_DISPLAY = '0.02' // Default flash loan adjustment tolerance shown in the UI (percent as string) -export const DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY = '0.5' +export const DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY = '0.1' // Default collateral slippage tolerance shown in the UI (percent as string) export const DEFAULT_COLLATERAL_SLIPPAGE_PERCENT_DISPLAY = '0.5' @@ -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 From f03fef103088148fcc4228d494569dc859651572 Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 12:43:06 -0500 Subject: [PATCH 2/7] update unit tests for mint plan --- src/domain/mint/planner/plan.ts | 2 +- tests/unit/domain/mint/plan.v2.spec.ts | 264 ++++++++++++++++++++++--- 2 files changed, 233 insertions(+), 33 deletions(-) diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index def86ea2..845b529c 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -221,7 +221,7 @@ export async function planMint({ } } -function solveFlashLoanAmountFromImpliedRates({ +export function solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset, collateralToDebtRateFromQuote, collateralToDebtRateFromManager, diff --git a/tests/unit/domain/mint/plan.v2.spec.ts b/tests/unit/domain/mint/plan.v2.spec.ts index 1664a949..2cd27905 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 manager preview + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -70,7 +88,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_584n, }, ]) @@ -84,21 +102,28 @@ 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, @@ -107,11 +132,30 @@ describe('planMint', () => { }) it('throws when manager previewed debt is below flash loan amount', async () => { - const quote = vi.fn(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 manager preview + 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 returns debt below flash loan amount @@ -140,11 +184,30 @@ describe('planMint', () => { }) it('throws when manager minimum debt is below flash loan amount', async () => { - const quote = vi.fn(async () => ({ - out: 1_300n, + 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 manager preview + 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([ @@ -174,11 +237,30 @@ describe('planMint', () => { }) it('throws when minimum shares from manager are below slippage floor', async () => { - const quote = vi.fn(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 manager preview + 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 @@ -209,7 +291,25 @@ 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, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // Initial manager preview + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -225,7 +325,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_600n, }, ]) @@ -239,12 +339,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 +356,38 @@ 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, + calls: [{ target: debt, data: '0x01' as Hex, value: 0n }], + slippageBps, + })) + + // Initial manager preview + readContract.mockImplementationOnce(async () => ({ + debt: 950n, + shares: 1_200n, + })) + + // Second quote + quote.mockImplementationOnce(async ({ slippageBps }: { slippageBps: number }) => ({ out: 1_200n, minOut: 1_100n, approvalTarget: debt, @@ -276,7 +403,7 @@ describe('planMint', () => { }, { debt: 1_100n, - shares: 1_500n, + shares: 1_600n, }, ]) @@ -290,19 +417,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 +474,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 manager-implied rate and sample is feasible', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromManager: 9_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + managerDebtAtInitialSample: 1_100n, + }) + + expect(flashLoanAmount).toBe(1_000n) + }) + + it('returns sampled manager debt when quote-implied rate is less than manager-implied rate and sample is not feasible', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromManager: 9_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + managerDebtAtInitialSample: 900n, + }) + + expect(flashLoanAmount).toBe(900n) + }) + + it('solves the inequality bound when quote-implied rate is greater than manager-implied rate', () => { + const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ + equityInCollateralAsset: 500n, + collateralToDebtRateFromQuote: 8_000n, + collateralToDebtRateFromManager: 7_000n, + exchangeRateScale: 10_000n, + flashLoanAmountInitial: 1_000n, + managerDebtAtInitialSample: 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, + collateralToDebtRateFromManager: 2n, + exchangeRateScale: 1_000n, + flashLoanAmountInitial: 1_000n, + managerDebtAtInitialSample: 900n, + }) + + // Formula: + // F_max = (2 * 3 * 1) / (1000 * (3 - 2)) + // = 6 / 1000 + // = 0 (integer division) + // Fallback should return flashLoanAmountInitial. + expect(flashLoanAmount).toBe(1_000n) + }) +}) From c0d73bf5f8400a79d122f70954f5291563eac395 Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 13:07:38 -0500 Subject: [PATCH 3/7] update default flash loan adjustment --- src/features/leverage-tokens/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/leverage-tokens/constants.ts b/src/features/leverage-tokens/constants.ts index ade0b64a..c5dc87dc 100644 --- a/src/features/leverage-tokens/constants.ts +++ b/src/features/leverage-tokens/constants.ts @@ -17,7 +17,7 @@ export const DEFAULT_SWAP_SLIPPAGE_PERCENT_DISPLAY = '0.01' export const DEFAULT_COLLATERAL_SWAP_ADJUSTMENT_PERCENT_DISPLAY = '0.02' // Default flash loan adjustment tolerance shown in the UI (percent as string) -export const DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY = '0.1' +export const DEFAULT_FLASH_LOAN_ADJUSTMENT_PERCENT_DISPLAY = '0.5' // Default collateral slippage tolerance shown in the UI (percent as string) export const DEFAULT_COLLATERAL_SLIPPAGE_PERCENT_DISPLAY = '0.5' From 0f4a78fbc4cc4a985507359d8460eb4ff100f279 Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 13:13:56 -0500 Subject: [PATCH 4/7] update ternary --- src/domain/mint/planner/plan.ts | 10 +++------- tests/unit/domain/mint/plan.v2.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index 845b529c..bb1b06bb 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -240,13 +240,9 @@ export function solveFlashLoanAmountFromImpliedRates({ // Under the linearized model, quoteRate <= managerRate means there is no // finite upper bound for debt >= flash-loan. Use the sampled point that is // already observed via exact quote + manager preview. - const sampledFeasibleFlashLoanAmount = - managerDebtAtInitialSample >= flashLoanAmountInitial - ? flashLoanAmountInitial - : managerDebtAtInitialSample - return sampledFeasibleFlashLoanAmount > 0n - ? sampledFeasibleFlashLoanAmount - : flashLoanAmountInitial + return managerDebtAtInitialSample >= flashLoanAmountInitial + ? flashLoanAmountInitial + : managerDebtAtInitialSample } // Derive a bound for flash-loan debt F such that predicted manager debt covers repayment. diff --git a/tests/unit/domain/mint/plan.v2.spec.ts b/tests/unit/domain/mint/plan.v2.spec.ts index 2cd27905..13bb1111 100644 --- a/tests/unit/domain/mint/plan.v2.spec.ts +++ b/tests/unit/domain/mint/plan.v2.spec.ts @@ -476,7 +476,7 @@ describe('planMint', () => { }) describe('solveFlashLoanAmountFromImpliedRates', () => { - it('returns initial flash loan when quote-implied rate is less than manager-implied rate and sample is feasible', () => { + it('returns initial flash loan when quote-implied rate is less than manager-implied rate', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 500n, collateralToDebtRateFromQuote: 8_000n, @@ -489,7 +489,7 @@ describe('solveFlashLoanAmountFromImpliedRates', () => { expect(flashLoanAmount).toBe(1_000n) }) - it('returns sampled manager debt when quote-implied rate is less than manager-implied rate and sample is not feasible', () => { + it('returns sampled manager debt when quote-implied rate is less than manager-implied rate', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 500n, collateralToDebtRateFromQuote: 8_000n, From 952966caed1ac075550b2127699533bc68d88eaf Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 13:50:21 -0500 Subject: [PATCH 5/7] set initial quote rate to zero ternary --- src/domain/mint/planner/plan.ts | 19 ++++- src/lib/observability/sentry.ts | 4 +- tests/unit/domain/mint/plan.v2.spec.ts | 99 ++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index bb1b06bb..c8521962 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -95,7 +95,9 @@ export async function planMint({ ), ) const collateralToDebtRateFromQuote = - (flashLoanAmountInitial * exchangeRateScale) / debtToCollateralQuoteInitial.out + debtToCollateralQuoteInitial.out > 0n + ? (flashLoanAmountInitial * exchangeRateScale) / debtToCollateralQuoteInitial.out + : 0n const collateralToDebtRateFromManager = (managerPreviewInitial.debt * exchangeRateScale) / (equityInCollateralAsset + debtToCollateralQuoteInitial.out) @@ -118,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: [ 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/unit/domain/mint/plan.v2.spec.ts b/tests/unit/domain/mint/plan.v2.spec.ts index 13bb1111..bc7dc81c 100644 --- a/tests/unit/domain/mint/plan.v2.spec.ts +++ b/tests/unit/domain/mint/plan.v2.spec.ts @@ -131,6 +131,105 @@ describe('planMint', () => { ) }) + 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() From 3c4a764b9ae92f7a3733fe585a9eeff4a63401aa Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 14:03:00 -0500 Subject: [PATCH 6/7] update mint integration test flash loan adjustment and defaults --- tests/integration_new/helpers/mint.ts | 40 +++++++++++++++------------ 1 file changed, 22 insertions(+), 18 deletions(-) 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 } From b111ba68c75eeaf69a7a41fbbf19fb98e2fd2661 Mon Sep 17 00:00:00 2001 From: chad-js Date: Thu, 12 Feb 2026 14:12:40 -0500 Subject: [PATCH 7/7] update var names --- src/domain/mint/planner/plan.ts | 46 ++++++++++++------------ tests/unit/domain/mint/plan.v2.spec.ts | 50 +++++++++++++------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/src/domain/mint/planner/plan.ts b/src/domain/mint/planner/plan.ts index c8521962..91546bc7 100644 --- a/src/domain/mint/planner/plan.ts +++ b/src/domain/mint/planner/plan.ts @@ -79,7 +79,7 @@ export async function planMint({ slippageBps: swapSlippageBps, }) - const managerPreviewInitial = await publicClient.readContract({ + const previewDepositInitial = await publicClient.readContract({ address: getContractAddresses(chainId).leverageManagerV2 as Address, abi: leverageManagerV2Abi, functionName: 'previewDeposit', @@ -98,17 +98,17 @@ export async function planMint({ debtToCollateralQuoteInitial.out > 0n ? (flashLoanAmountInitial * exchangeRateScale) / debtToCollateralQuoteInitial.out : 0n - const collateralToDebtRateFromManager = - (managerPreviewInitial.debt * exchangeRateScale) / + const collateralToDebtRateFromPreviewDeposit = + (previewDepositInitial.debt * exchangeRateScale) / (equityInCollateralAsset + debtToCollateralQuoteInitial.out) const flashLoanAmountUnadjusted = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset, collateralToDebtRateFromQuote, - collateralToDebtRateFromManager, + collateralToDebtRateFromPreviewDeposit, exchangeRateScale, flashLoanAmountInitial, - managerDebtAtInitialSample: managerPreviewInitial.debt, + previewDepositDebtInitialSample: previewDepositInitial.debt, }) const flashLoanAmount = applySlippageFloor(flashLoanAmountUnadjusted, flashLoanAdjustmentBps) @@ -241,44 +241,44 @@ export async function planMint({ export function solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset, collateralToDebtRateFromQuote, - collateralToDebtRateFromManager, + collateralToDebtRateFromPreviewDeposit, exchangeRateScale, flashLoanAmountInitial, - managerDebtAtInitialSample, + previewDepositDebtInitialSample, }: { equityInCollateralAsset: bigint collateralToDebtRateFromQuote: bigint - collateralToDebtRateFromManager: bigint + collateralToDebtRateFromPreviewDeposit: bigint exchangeRateScale: bigint flashLoanAmountInitial: bigint - managerDebtAtInitialSample: bigint + previewDepositDebtInitialSample: bigint }): bigint { - if (collateralToDebtRateFromQuote <= collateralToDebtRateFromManager) { - // Under the linearized model, quoteRate <= managerRate means there is no - // finite upper bound for debt >= flash-loan. Use the sampled point that is - // already observed via exact quote + manager preview. - return managerDebtAtInitialSample >= flashLoanAmountInitial + 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 - : managerDebtAtInitialSample + : previewDepositDebtInitialSample } - // Derive a bound for flash-loan debt F such that predicted manager debt covers repayment. + // 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 = collateralToDebtRateFromManager / exchangeRateScale - // where m is debt-per-collateral from the manager sample + // - m = collateralToDebtRateFromPreviewDeposit / exchangeRateScale + // where m is debt-per-collateral from the previewDeposit sample // // Approximate relationships: // - quoteOutCollateral(F) ~= F / q - // - managerDebt(F) ~= m * (E + quoteOutCollateral(F)) + // - previewDepositDebt(F) ~= m * (E + quoteOutCollateral(F)) // ~= m * (E + F / q) // // Safety condition we want: - // - managerDebt(F) >= F + // - previewDepositDebt(F) >= F // // Solve: // m * (E + F / q) >= F @@ -293,11 +293,11 @@ export function solveFlashLoanAmountFromImpliedRates({ // Substituting and simplifying gives: // F <= (mScaled * qScaled * E) / (exchangeRateScale * (qScaled - mScaled)) const numerator = - collateralToDebtRateFromManager * collateralToDebtRateFromQuote * equityInCollateralAsset + collateralToDebtRateFromPreviewDeposit * collateralToDebtRateFromQuote * equityInCollateralAsset const denominator = - exchangeRateScale * (collateralToDebtRateFromQuote - collateralToDebtRateFromManager) + exchangeRateScale * (collateralToDebtRateFromQuote - collateralToDebtRateFromPreviewDeposit) - const maxFlashLoanSatisfyingInequality = numerator / denominator + 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 diff --git a/tests/unit/domain/mint/plan.v2.spec.ts b/tests/unit/domain/mint/plan.v2.spec.ts index bc7dc81c..6a982a27 100644 --- a/tests/unit/domain/mint/plan.v2.spec.ts +++ b/tests/unit/domain/mint/plan.v2.spec.ts @@ -65,7 +65,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -80,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, @@ -230,7 +230,7 @@ describe('planMint', () => { ) }) - it('throws when manager previewed debt is below flash loan amount', async () => { + it('throws when previewDeposit debt is below flash loan amount', async () => { const quote = vi.fn() // Initial quote @@ -242,7 +242,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -257,7 +257,7 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit returns debt below flash loan amount + // previewDeposit returns debt below flash loan amount multicall.mockResolvedValueOnce([ { debt: 800n, @@ -282,7 +282,7 @@ 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 () => { + it('throws when previewDeposit minimum debt is below flash loan amount', async () => { const quote = vi.fn() // Initial quote @@ -294,7 +294,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -335,7 +335,7 @@ describe('planMint', () => { ) }) - it('throws when minimum shares from manager are below slippage floor', async () => { + it('throws when minimum shares from previewDeposit are below slippage floor', async () => { const quote = vi.fn() // Initial quote @@ -347,7 +347,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -362,7 +362,7 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (minOut path) returns shares below minShares + // previewDeposit (minOut path) returns shares below minShares multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -401,7 +401,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -416,7 +416,7 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (with out) + manager previewDeposit (with minOut) + // previewDeposit (with out) + previewDeposit (with minOut) multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -479,7 +479,7 @@ describe('planMint', () => { slippageBps, })) - // Initial manager preview + // Initial previewDeposit readContract.mockImplementationOnce(async () => ({ debt: 950n, shares: 1_200n, @@ -494,7 +494,7 @@ describe('planMint', () => { slippageBps, })) - // manager previewDeposit (with out) + manager previewDeposit (with minOut) + // previewDeposit (with out) + previewDeposit (with minOut) multicall.mockResolvedValueOnce([ { debt: 1_300n, @@ -575,40 +575,40 @@ describe('planMint', () => { }) describe('solveFlashLoanAmountFromImpliedRates', () => { - it('returns initial flash loan when quote-implied rate is less than manager-implied rate', () => { + it('returns initial flash loan when quote-implied rate is less than previewDeposit-implied rate', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 500n, collateralToDebtRateFromQuote: 8_000n, - collateralToDebtRateFromManager: 9_000n, + collateralToDebtRateFromPreviewDeposit: 9_000n, exchangeRateScale: 10_000n, flashLoanAmountInitial: 1_000n, - managerDebtAtInitialSample: 1_100n, + previewDepositDebtInitialSample: 1_100n, }) expect(flashLoanAmount).toBe(1_000n) }) - it('returns sampled manager debt when quote-implied rate is less than manager-implied rate', () => { + it('returns sampled previewDeposit debt when quote-implied rate is less than previewDeposit-implied rate', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 500n, collateralToDebtRateFromQuote: 8_000n, - collateralToDebtRateFromManager: 9_000n, + collateralToDebtRateFromPreviewDeposit: 9_000n, exchangeRateScale: 10_000n, flashLoanAmountInitial: 1_000n, - managerDebtAtInitialSample: 900n, + previewDepositDebtInitialSample: 900n, }) expect(flashLoanAmount).toBe(900n) }) - it('solves the inequality bound when quote-implied rate is greater than manager-implied rate', () => { + it('solves the inequality bound when quote-implied rate is greater than previewDeposit-implied rate', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 500n, collateralToDebtRateFromQuote: 8_000n, - collateralToDebtRateFromManager: 7_000n, + collateralToDebtRateFromPreviewDeposit: 7_000n, exchangeRateScale: 10_000n, flashLoanAmountInitial: 1_000n, - managerDebtAtInitialSample: 950n, + previewDepositDebtInitialSample: 950n, }) // Formula: @@ -623,10 +623,10 @@ describe('solveFlashLoanAmountFromImpliedRates', () => { const flashLoanAmount = solveFlashLoanAmountFromImpliedRates({ equityInCollateralAsset: 1n, collateralToDebtRateFromQuote: 3n, - collateralToDebtRateFromManager: 2n, + collateralToDebtRateFromPreviewDeposit: 2n, exchangeRateScale: 1_000n, flashLoanAmountInitial: 1_000n, - managerDebtAtInitialSample: 900n, + previewDepositDebtInitialSample: 900n, }) // Formula: