Skip to content

fix(FlashMintDexV5): redeem-side WETH accounting must use balance delta - #225

Merged
ckoopmann merged 2 commits into
masterfrom
fix-flashmintv5-redeem-balance-accounting
May 4, 2026
Merged

ckoopmann merged 2 commits into
masterfrom
fix-flashmintv5-redeem-balance-accounting

Conversation

@ckoopmann

@ckoopmann ckoopmann commented May 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Three related redeem-side bugs in FlashMintDexV5, all rooted in the same assumption: that _sellComponentsForWeth can trust the per-component swap amounts and return values from DEXAdapterV5.swapExactTokensForTokens rather than the contract's actual token balances. Switching to balance-based accounting (read WETH delta over the loop, swap min(target, actualBalance) per component, skip 0-amount components) closes all three.

The three bugs

A. Noop-swap inflation (tested in this PR). DEXAdapterV5.swapExactTokensForTokens short-circuits an empty path by returning _amountIn unchanged (DEXAdapterV5.sol:114-116). When the caller passes noopSwap (e.g. to leave a USDC dust component as residue rather than route a tiny ~11_000 wei swap through Uniswap V3), _sellComponentsForWeth sums that USDC _amountIn into totalWethReceived as if it were WETH. The downstream _swapWethForPaymentToken then over-quotes the WETH→output bridge by exactly the dust amount and reverts with STF / SafeERC20: low-level call failed.

B. Pre/post-sync component drift on dust component (verified later via SDK e2e). SDK reads pre-sync componentUnits[i]=11 wei from the issuance module's view; DebtIssuanceModuleV3.redeem(...) syncs first and the V3 tokenTransferBuffer clamp can flip the pull direction by ±1 wei. Contract ends up holding 10 wei but the swap call passes 11 → ERC20: transfer amount exceeds balance at the component swap's transferFrom. This is the long-standing uSUI3x@1 redeem failure documented in the SDK's test-scenarios.ts.

C. Zero-amount components crash the underlying router (verified later via SDK e2e). When getRequiredComponentRedemptionUnits returns 0 for a dust component, swapExactTokensForTokens(0, ...) is rejected by UniV3 / Aerodrome's quoter. Currently worked around in the SDK by resolveDexV5SwapDataForAmount substituting noopSwap; with the contract-side fix the SDK substitution becomes optional.

What this PR does

  • Commit 1 (this commit): adds a regression spec that reproduces Bug A. Issues 1 uSOL3x via the working two-component path, then redeems with noopSwap for the USDC dust component. CI on this commit should be red with SafeERC20: low-level call failed.
  • Commit 2 (next commit): rewrites _sellComponentsForWeth to use balance-based accounting, swap min(componentUnits[i], balanceOf(component)), and skip 0-amount components. CI on this commit should be green for the new spec and all existing FlashMintDexV5 specs.

Bugs B and C cannot be cleanly reproduced inside index-coop-smart-contracts integration tests at the current pinned fork block (B needs a uSUI3x whale to source SetTokens since uSUI3x@1 issuance can't construct an 11-wei UniV3 swap; C needs a setAmount small enough to trigger the tokenTransferBuffer clamp, which on uSOL3x is sub-attomole). They will be verified in the SDK e2e suite once the fix lands and the SDK's FlashMintDexV5 constant is bumped to the new deployment.

Test plan

  • New spec test/integration/base/flashMintDexV5.spec.ts › regression: redeem-side noopSwap on dust component must not inflate WETH accounting reverts on commit 1 (red — expected).
  • After commit 2, the same spec passes and all other FlashMintDexV5 specs continue passing.
  • After release: SDK bumps FlashMintDexV5 address, re-enables uSUI3x@1 in test-scenarios.ts, and confirms via e2e that bugs B and C are gone.

Adds a regression spec that issues 1 uSOL3x via the working two-component
path, then attempts to redeem it back to WETH using `noopSwap` for the USDC
dust component. Currently reverts with `SafeERC20: low-level call failed` —
DEXAdapterV5 short-circuits an empty-path swap by returning `_amountIn`, and
`_sellComponentsForWeth` sums those returns into `totalWethReceived` as if
they were WETH. The bridge swap then over-quotes by exactly the dust amount
and the SafeERC20 transferFrom inside the UniV3 swap path reverts.

Test currently fails (expected). Fix is in the next commit.
@ckoopmann ckoopmann changed the title fix(FlashMintDexV5): redeem-side noopSwap inflates WETH accounting fix(FlashMintDexV5): redeem-side WETH accounting must use balance delta May 4, 2026
Rewrites _sellComponentsForWeth to read WETH delta over the loop instead of
summing per-component swap return values, swap min(componentUnits[i],
balanceOf(component)) instead of trusting the issuance module's view-derived
target, and skip components whose effective amount is 0.

Closes three related redeem-side bugs:

  A. Noop-swap inflation. DEXAdapterV5.swapExactTokensForTokens short-
     circuits an empty path by returning _amountIn unchanged. Pre-fix
     _sellComponentsForWeth summed those into totalWethReceived as if they
     were WETH, so any caller passing noopSwap for a non-zero component
     inflated the bridge total by the input amount in the input token's
     units → STF / SafeERC20: low-level call failed at the WETH→output
     bridge. Reproduced in the spec from the previous commit.

  B. Pre/post-sync drift on dust components. DebtIssuanceModuleV3.redeem's
     internal sync + tokenTransferBuffer clamp can flip the pull direction
     by ±1 wei vs the SDK's pre-quote view. Pre-fix the contract called
     swap(componentUnits[i], …) with the stale target → ERC20: transfer
     amount exceeds balance. Post-fix it caps at actual balance. (Verified
     via SDK e2e once the fix ships.)

  C. Zero-amount components crash UniV3 / Aerodrome routers. Pre-fix the
     SDK substituted noopSwap to dodge this; combined with bug A above
     that workaround was unsound for non-zero amounts. Post-fix the
     contract skips 0-amount components internally, so the SDK
     substitution becomes optional. (Verified via SDK e2e once the fix
     ships.)

Issue side is unchanged — the deficit buffer (#223) and
_syncExternalPositions (#224) already cover the analogous drift there, and
the issue path doesn't have a noopSwap step so bug A doesn't apply.
@ckoopmann
ckoopmann merged commit c852c02 into master May 4, 2026
3 checks passed
@ckoopmann
ckoopmann deleted the fix-flashmintv5-redeem-balance-accounting branch May 4, 2026 11:58
@github-actions

github-actions Bot commented May 4, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 0.45.3 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant