fix(FlashMintDexV5): pad component swap target to absorb V3 rounding gap - #223
Merged
Merged
Conversation
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.
|
🎉 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.