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
47 changes: 43 additions & 4 deletions contracts/exchangeIssuance/FlashMintDexV5.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -614,17 +648,22 @@ 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(
_redeemParams.setToken.getExternalPositionModules(components[i]).length == 0,
"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);
}

/**
Expand Down
119 changes: 119 additions & 0 deletions test/integration/base/flashMintDexV5.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading