Skip to content

fix(FlashMintDexV5): sync external position modules before issue & redeem - #224

Merged
ckoopmann merged 1 commit into
masterfrom
flashmintv5-add-sync
May 3, 2026
Merged

ckoopmann merged 1 commit into
masterfrom
flashmintv5-add-sync

Conversation

@ckoopmann

Copy link
Copy Markdown
Collaborator

DebtIssuanceModuleV3.getRequiredComponentIssuanceUnits returns the SetToken's stored per-share equity unit for components held as external positions (e.g. Morpho leverage module). That stored unit only advances when the module's _sync writes to it. The V3 internal issue() flow runs the module's pre-hook (which calls sync) before reading the unit, so the pull sees a fresher value than the view returned. For products whose external position has drifted from live protocol state — uXRP2x in our delevered set, where the gap is ~7530 wei per set — the FlashMint runs short and reverts with ERC20InsufficientBalance.

Add _syncExternalPositions(setToken) that iterates the SetToken's components × external position modules and best-effort low-level-calls sync(address) on each. Call it at the top of _issueExactSetFromWeth and _redeem so the contract sees the same per-share unit the issuance module's internal flow will. This mirrors the existing pattern used by FlashMintLeveragedMorphoV2 / FlashMintLeveragedMorphoAaveLM, but generalised: those take their leverage module via constructor; this inspects the SetToken at runtime and works for any external position module that exposes sync(address).

Add a regression spec that issues uXRP2x at setAmount=0.005 ether — would revert with the patched-but-buffer-only 0.45.1 contract (deficit of ~7530 wei × 0.005 sets ≫ buffer of 1 wei/set), passes here.

…deem

DebtIssuanceModuleV3.getRequiredComponentIssuanceUnits returns the
SetToken's *stored* per-share equity unit for components held as external
positions (e.g. Morpho leverage module). That stored unit only advances
when the module's _sync writes to it. The V3 internal issue() flow runs
the module's pre-hook (which calls sync) before reading the unit, so the
pull sees a fresher value than the view returned. For products whose
external position has drifted from live protocol state — uXRP2x in our
delevered set, where the gap is ~7530 wei per set — the FlashMint runs
short and reverts with ERC20InsufficientBalance.

Add `_syncExternalPositions(setToken)` that iterates the SetToken's
components × external position modules and best-effort low-level-calls
sync(address) on each. Call it at the top of _issueExactSetFromWeth and
_redeem so the contract sees the same per-share unit the issuance
module's internal flow will. This mirrors the existing pattern used by
FlashMintLeveragedMorphoV2 / FlashMintLeveragedMorphoAaveLM, but
generalised: those take their leverage module via constructor; this
inspects the SetToken at runtime and works for any external position
module that exposes sync(address).

Add a regression spec that issues uXRP2x at setAmount=0.005 ether — would
revert with the patched-but-buffer-only 0.45.1 contract (deficit of
~7530 wei × 0.005 sets ≫ buffer of 1 wei/set), passes here.
@ckoopmann
ckoopmann merged commit b1856ca into master May 3, 2026
3 checks passed
@ckoopmann
ckoopmann deleted the flashmintv5-add-sync branch May 3, 2026 14:17
@github-actions

github-actions Bot commented May 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 0.45.2 🎉

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