Skip to content

fix(FlashMintDexV5): pad component swap target to absorb V3 rounding gap - #223

Merged
ckoopmann merged 2 commits into
masterfrom
fix-flash-mint-dex-v5-rounding
Apr 30, 2026
Merged

ckoopmann merged 2 commits into
masterfrom
fix-flash-mint-dex-v5-rounding

Conversation

@ckoopmann

Copy link
Copy Markdown
Collaborator

No description provided.

Adds a regression spec that issues uSOL2x at setAmount=10 ether against a
recent post-disengage block. The transaction reverts with
ERC20InsufficientBalance because _buyComponentsWithWeth swaps for the
amount returned by DebtIssuanceModuleV3.getRequiredComponentIssuanceUnits
(balance-derived equity units via _getTotalIssuanceUnitsFromBalances)
while issue() internally pulls equity units derived from stored position
units (_getTotalIssuanceUnits + preciseMulCeil). The two diverge by 1
wei per set when the SetToken's component balance has been rounded
relative to its stored default position; at setAmount=10 that's a 10-wei
shortfall on the swap output, which trips SafeERC20.safeTransferFrom
inside V3's _resolveEquityPositions hook.

Also bumps the fork block from 45_113_000 to 45_340_000 so the existing
specs run against state that exposes the rounding gap (at the original
block all six products are freshly disengaged with balance==position).
DebtIssuanceModuleV3.getRequiredComponentIssuanceUnits computes equity
units from current SetToken balances (_getTotalIssuanceUnitsFromBalances)
and adds tokenTransferBuffer, while issue() internally pulls equity
units derived from stored position units (_getTotalIssuanceUnits +
preciseMulCeil) and again adds tokenTransferBuffer. When the SetToken's
component balance has been rounded relative to its stored default
position the position-based number can exceed the balance-based one by
up to 1 wei per (1e18) unit of setAmount per component. The buffers
cancel out, leaving _buyComponentsWithWeth's swap output (sized off the
external view) short of what issue() actually pulls — which surfaces as
ERC20InsufficientBalance inside V3._resolveEquityPositions.

Pad each component's swap target by `amountSetToken / 1e18 + 1` — the
worst-case asymmetry per component. Apply the same buffer to
_getWethCostsForIssue so getIssueExactSet returns a quote that matches
the real swap path. Excess components stay in the contract and remain
recoverable via withdrawTokens.
@ckoopmann ckoopmann changed the title Fix flash mint dex v5 rounding fix(FlashMintDexV5): pad component swap target to absorb V3 rounding gap Apr 30, 2026
@ckoopmann
ckoopmann merged commit 0126bbf into master Apr 30, 2026
3 checks passed
@ckoopmann
ckoopmann deleted the fix-flash-mint-dex-v5-rounding branch April 30, 2026 08:11
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.45.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

ckoopmann added a commit that referenced this pull request 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 added a commit that referenced this pull request May 4, 2026
…ta (#225)

* test(FlashMintDexV5): reproduce redeem-side noopSwap WETH inflation bug

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.

* fix(FlashMintDexV5): balance-based redeem-side WETH accounting

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.
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