diff --git a/contracts/exchangeIssuance/FlashMintDexV5.sol b/contracts/exchangeIssuance/FlashMintDexV5.sol index 2820cbff..ee7bca88 100644 --- a/contracts/exchangeIssuance/FlashMintDexV5.sol +++ b/contracts/exchangeIssuance/FlashMintDexV5.sol @@ -596,7 +596,41 @@ contract FlashMintDexV5 is Ownable, ReentrancyGuard { } /** - * Sells redeemed components for WETH. + * Sells redeemed components for WETH using balance-based accounting. + * + * Three classes of bug were observed when this function trusted the per-component + * `swapExactTokensForTokens` return value as the WETH produced and the issuance + * module's view-derived `componentUnits[i]` as the swap input: + * + * (A) Caller passes `noopSwap` (Exchange.None / empty path) for a non-zero + * component to leave it as residue. `DEXAdapterV5.swapExactTokensForTokens` + * short-circuits an empty path by returning `_amountIn` unchanged + * (see DEXAdapterV5.sol around the `path.length == 0` branch). The summed + * `_amountIn` is in the input token's units, not WETH, so `totalWethReceived` + * is over-stated by exactly that amount. The downstream WETH→output bridge + * then over-quotes the contract's actual WETH balance and reverts with + * `STF` (UniV3 TransferHelper) or `SafeERC20: low-level call failed`. + * + * (B) Pre/post-sync drift on a dust component. Even after `_syncExternalPositions`, + * `DebtIssuanceModuleV3.redeem(...)` re-runs sync and the `tokenTransferBuffer` + * clamp can flip the pull direction by ±1 wei vs the value the SDK observed + * via `getRequiredComponentRedemptionUnits` at quote time. The contract ends + * up holding `componentUnits[i] - 1` of the component but tries to swap + * `componentUnits[i]` → `ERC20: transfer amount exceeds balance` at the swap + * call's transferFrom. + * + * (C) The issuance module reports 0 wei for a dust component. Calling + * `swapExactTokensForTokens(0, ...)` on UniV3 / Aerodrome reverts inside the + * quoter / router because they reject 0-amount calls. + * + * Fix: + * - Read the contract's WETH balance before and after the loop; take the delta. + * A noop swap leaves WETH unchanged → contributes 0 to the total instead of + * spurious `_amountIn`. (Closes A.) + * - Swap `min(componentUnits[i], actualBalance)` so the swap call never asks for + * more of the component than the contract actually holds. (Closes B.) + * - Skip components whose effective swap amount is 0 — there is nothing to swap + * and the underlying router would reject the 0-amount call anyway. (Closes C.) * * @param _redeemParams Struct containing addresses, amounts, and swap data for issuance * @@ -614,7 +648,7 @@ contract FlashMintDexV5 is Ownable, ReentrancyGuard { ); require(components.length == _redeemParams.componentSwapData.length, "FlashMint: INVALID NUMBER OF COMPONENTS IN SWAP DATA"); - totalWethReceived = 0; + uint256 wethBefore = IERC20(WETH).balanceOf(address(this)); for (uint256 i = 0; i < components.length; i++) { if (!_redeemParams.isDebtIssuance) { require( @@ -622,9 +656,14 @@ contract FlashMintDexV5 is Ownable, ReentrancyGuard { "FlashMint: EXTERNAL POSITION MODULES NOT SUPPORTED" ); } - uint256 wethBought = dexAdapter.swapExactTokensForTokens(componentUnits[i], 0, _redeemParams.componentSwapData[i]); - totalWethReceived = totalWethReceived.add(wethBought); + uint256 actualBalance = IERC20(components[i]).balanceOf(address(this)); + uint256 amountToSwap = componentUnits[i] < actualBalance ? componentUnits[i] : actualBalance; + if (amountToSwap == 0) { + continue; + } + dexAdapter.swapExactTokensForTokens(amountToSwap, 0, _redeemParams.componentSwapData[i]); } + totalWethReceived = IERC20(WETH).balanceOf(address(this)).sub(wethBefore); } /** diff --git a/test/integration/base/flashMintDexV5.spec.ts b/test/integration/base/flashMintDexV5.spec.ts index 03d0311d..0f5f88d5 100644 --- a/test/integration/base/flashMintDexV5.spec.ts +++ b/test/integration/base/flashMintDexV5.spec.ts @@ -411,6 +411,125 @@ if (process.env.INTEGRATIONTEST) { }); }); + // Regression: when the SDK passes `noopSwap` for a non-zero-amount + // component on the redeem side (e.g. to leave the USDC dust component as + // residue rather than route a tiny ~11_000 wei swap through Uniswap V3), + // the contract must treat it as "no WETH produced" — not "input amount + // produced as WETH". + // + // Bug being reproduced: `DEXAdapterV5.swapExactTokensForTokens` short- + // circuits an empty path by returning `_amountIn` unchanged + // (DEXAdapterV5.sol:114-116). Pre-fix, `_sellComponentsForWeth` sums those + // returns into `totalWethReceived` as if every per-component call produced + // WETH — so a noop swap of 11_000 wei USDC inflates the total by 11_000 + // wei. `_swapWethForPaymentToken` then tries to bridge the inflated total + // and reverts with `STF` (UniV3 TransferHelper) or + // `SafeERC20: low-level call failed` because the contract's actual WETH + // balance is short by exactly the dust amount. + // + // Post-fix expectation (balance-based WETH accounting in + // `_sellComponentsForWeth`): noop calls add 0 to the WETH delta, the + // bridge swap is sized against real balance, and the redeem succeeds with + // USDC dust left behind in the contract. + describe("regression: redeem-side noopSwap on dust component must not inflate WETH accounting", () => { + const setAmount = ether(1); + let setToken: IERC20; + + // uSOL3x component order is [uSOL, USDC]. Issue uses real swaps for + // both components (the contract needs USDC to deposit into Morpho). + const componentSwapDataIssue: SwapData[] = [ + { + path: [wethAddress, uSOL], + fees: [], + tickSpacing: [slipstreamTickSpacing], + pool: ADDRESS_ZERO, + poolIds: [], + exchange: Exchange.AerodromeSlipstream, + }, + { + path: [wethAddress, usdcAddress], + fees: [500], + tickSpacing: [], + pool: ADDRESS_ZERO, + poolIds: [], + exchange: Exchange.UniV3, + }, + ]; + + // Redeem swaps the collateral for real but passes `noopSwap` for USDC. + const componentSwapDataRedeem: SwapData[] = [ + { + path: [uSOL, wethAddress], + fees: [], + tickSpacing: [slipstreamTickSpacing], + pool: ADDRESS_ZERO, + poolIds: [], + exchange: Exchange.AerodromeSlipstream, + }, + noopSwap, + ]; + + before(async () => { + setToken = (await ethers.getContractAt("IERC20", uSOL3x)) as IERC20; + await flashMintDexV5.approveSetToken(uSOL3x, debtIssuanceModuleAddress); + }); + + it("redeems uSOL3x to WETH with noopSwap on USDC dust (would revert pre-fix)", async () => { + // Issue 1 uSOL3x via the working path. + const wethEstimate = await flashMintDexV5.callStatic.getIssueExactSet( + { + setToken: uSOL3x, + amountSetToken: setAmount, + componentSwapData: componentSwapDataIssue, + issuanceModule: debtIssuanceModuleAddress, + isDebtIssuance: true, + }, + noopSwap, + ); + const maxWeth = wethEstimate.mul(105).div(100); + await fundWeth(owner.address, maxWeth); + await weth.approve(flashMintDexV5.address, maxWeth); + await flashMintDexV5.issueExactSetFromERC20( + { + setToken: uSOL3x, + amountSetToken: setAmount, + componentSwapData: componentSwapDataIssue, + issuanceModule: debtIssuanceModuleAddress, + isDebtIssuance: true, + }, + { + token: wethAddress, + limitAmt: maxWeth, + swapDataTokenToWeth: noopSwap, + swapDataWethToToken: noopSwap, + }, + 0, + ); + + // Redeem with noopSwap on the USDC component. Pre-fix: reverts because + // the bridge swap is sized against the inflated `totalWethReceived`. + await setToken.connect(owner.wallet).approve(flashMintDexV5.address, setAmount); + const wethBefore = await weth.balanceOf(owner.address); + await flashMintDexV5.redeemExactSetForERC20( + { + setToken: uSOL3x, + amountSetToken: setAmount, + componentSwapData: componentSwapDataRedeem, + issuanceModule: debtIssuanceModuleAddress, + isDebtIssuance: true, + }, + { + token: wethAddress, + limitAmt: 1, + swapDataTokenToWeth: noopSwap, + swapDataWethToToken: noopSwap, + }, + ); + const wethAfter = await weth.balanceOf(owner.address); + expect(wethAfter).to.be.gt(wethBefore); + }); + }); + // Regression for the larger Morpho-position drift on uXRP2x. The stored // external-position unit on uXRP2x lags actual Morpho collateral by // ~7500 wei per set (vs ≤1 wei/set for uSOL/uSUI products), so the