feat: FlashMintAaveDelevered for Arbitrum AAVE2x/LINK2x - #226
Merged
Merged
Conversation
After their disengage() fully closed the borrows, AAVE2x and LINK2x on
Arbitrum sit at LR=1.0x with components:
AAVE2x: [aArbAAVE] (clean, single component)
LINK2x: [aArbLINK, USDT (9150 wei)] (partial — USDT dust left over)
The leveraged FlashMint contracts (FlashMintLeveragedAaveFL) revert with
"TOO MANY COMPONENTS" / "TOO MANY EQUITY POSITIONS" because they assume the
2-component [collateralAToken, debtToken] shape. FlashMintDexV5's per-component
DEX swap loop can't help either — aTokens have no DEX liquidity.
This adds a single-purpose ~80-line redeemer that:
- Pulls SetToken from caller, calls DebtIssuanceModuleV3.redeem
- For each component: if it exposes IAToken.UNDERLYING_ASSET_ADDRESS(),
burns 1:1 for underlying via IPool.withdraw(asset, MAX_UINT, recipient).
Otherwise transfers the component as-is.
The try/catch on UNDERLYING_ASSET_ADDRESS() makes the contract state-
transparent across a future operator cleanup that converts aToken positions
to underlying positions inside the SetToken — same bytecode handles both
states, no SDK config change required.
Issuance is intentionally not supported — these products are deprecated and
the user only needs an exit ramp.
Test plan (passes 10/10 on Arbitrum fork at block 459_500_000):
- Asserts aArbAAVE.UNDERLYING_ASSET_ADDRESS() returns AAVE address
- Asserts aArbLINK.UNDERLYING_ASSET_ADDRESS() returns LINK address
- Asserts USDT does NOT expose UNDERLYING_ASSET_ADDRESS() (catch fires)
- Asserts IPool.withdraw(asset, MAX_UINT, recipient) burns the contract's
full aToken balance and returns the actual underlying amount
- For each of AAVE2x and LINK2x: issues via DebtIssuanceModuleV3.issue
using aTokens funded from the Aave V3 Arbitrum Collector, then redeems
via the new redeemer. Asserts the recipient receives underlying within
10 wei (lower) / 10 bps (upper, accounts for aToken interest accrual)
of the V3 module's view-derived per-share amount, and the redeemer
holds zero of all relevant tokens after the call.
- For LINK2x: also asserts the USDT dust is forwarded as-is.
- recipient=address(0) sentinel substitutes msg.sender (lets SDKs encode
the calldata without knowing the connected wallet).
- Reverts on zero amount.
Companion SDK PR will route AAVE2x and LINK2x through this contract.
Extends AaveV3DeleveredRedeemer with two additional exit modes so callers
can redeem AAVE2x / LINK2x to any single ERC20 (or native ETH) in one tx,
not just the SetToken's headline underlying.
- redeem(set, amt, recipient)
Existing exit. Per-component as-is delivery (aTokens are unwrapped via
Pool.withdraw to underlying; non-aToken components forwarded raw).
No DEX involvement; cheapest. Recipient gets AAVE (and dust USDT for
LINK2x). Unchanged.
- redeemForToken(set, amt, output, minOut, recipient, swapData[])
New. Per-component DEX swap into a chosen ERC20. For each component
the contract first unwraps any aToken (via Pool.withdraw to underlying
held by the contract), then swaps that underlying into `output` using
DEXAdapterV3. Skips the swap when the component already equals the
output (DEXAdapterV3 short-circuits on path=[]). Reverts if the total
delivered is below `minOut`.
- redeemForETH(set, amt, minEth, recipient, swapData[])
Same as redeemForToken with WETH as the swap target, plus a final
WETH.withdraw to deliver native ETH to the recipient.
- getRedeemQuote(set, amt, output, swapData[]) (view-via-eth_call)
Returns the predicted total `output` a redemption would deliver. Aave
V3 aTokens redeem 1:1 with their underlying so per-aToken contribution
is just the per-share unit times the amount; non-aToken components
run through DEXAdapterV3.getAmountOut.
The existing state-transparency property (try/catch on
UNDERLYING_ASSET_ADDRESS) is preserved: each component is independently
detected as aToken-vs-not at call time, so the same bytecode handles both
the current aToken-as-component state and a future post-cleanup
underlying-as-component state without an SDK config change.
Constructor signature changed: now also takes a DEXAdapterV3.Addresses
struct (router/quoter/WETH addresses for the host chain). Deploy helper
in utils/deploys/deployExtensions.ts updated accordingly — the
AaveV3DeleveredRedeemer artifact must be linked against the
DEXAdapterV3 library at deploy time (helper handles the link).
Test plan (passes 19/19 on Arbitrum fork at block 459_500_000):
- Constructor wires immutables (pool, issuanceModule, addresses)
- aToken interface assumption (3 specs)
- IPool.withdraw(MaxUint256) semantics
- AAVE2x and LINK2x: simple redeem to recipient (2 specs)
- AAVE2x and LINK2x: address(0) recipient sentinel (2 specs)
- AAVE2x → WETH, ETH, USDC, AAVE via redeemForToken / redeemForETH
(4 specs covering single-component product, two-hop UniV3 path,
and noop SwapData passthrough)
- LINK2x → WETH, ETH, LINK via redeemForToken / redeemForETH
(3 specs covering multi-component product including USDT dust swap)
- Safety: zero amount, insufficient output, swap-data length mismatch
…ventions
Renames the Arbitrum delever-redemption contract from AaveV3DeleveredRedeemer
to FlashMintAaveDelevered and reshapes its external interface to match the
existing FlashMintLeveraged / FlashMintLeveragedAaveFL conventions so SDK
adapters and downstream tooling can reuse the same patterns.
External interface:
- redeemExactSetForETH(setToken, setAmount, minAmountOutputToken, swapData[])
- redeemExactSetForERC20(setToken, setAmount, outputToken, minAmountOutputToken, swapData[])
- getRedeemExactSet(setToken, setAmount, outputToken, swapData[]) view-style
- constants.ETH_ADDRESS sentinel matches DEXAdapter convention
- FlashRedeem event with the same indexed fields as FlashMintLeveraged
State variables:
- setController, aaveV3Pool, debtIssuanceModule, addresses
- validSetToken modifier (calls setController.isSet) — matches FlashMintDexV5
Removed:
- The simple `redeem(setToken, amount, recipient)` from the previous draft
is replaced by `redeemExactSetForERC20(setToken, amount, headlineUnderlying,
0, [noopSwap...])` — same outcome, one consistent surface.
- The `address(0) recipient` sentinel — every other FlashMint sends to
msg.sender, so we do too. SDK encodes the connected wallet as the from
address.
Constructor signature: now also takes IController (setController) for the
Set-validity check, matching FlashMintLeveragedAaveFL. Deploy helper updated.
Test plan (passes 17/17 on Arbitrum fork at block 459_500_000):
- Constructor wires immutables (setController, aaveV3Pool, debtIssuanceModule,
DEX addresses)
- aToken interface assumption (3 specs)
- IPool.withdraw(MaxUint256) semantics
- 7 product × output-token cases via redeemExactSetForETH / redeemExactSetForERC20:
AAVE2x → AAVE / WETH / ETH / USDC
LINK2x → LINK / WETH / ETH
(covering single-component product, multi-component, multi-hop paths,
no-op SwapData passthrough, USDT-dust handling, and ETH unwrap)
- 5 safety reverts: zero amount × 2 functions, insufficient output, swap-data
length mismatch, non-Set token
This is a force-push to the existing PR branch — the previous integration spec
covering the same flows under the old names is replaced with this one.
|
🎉 This PR is included in version 0.46.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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.
Summary
After their
disengage()fully closed the borrows, Arbitrum AAVE2x and LINK2x sit at LR=1.0× in awkward post-disengage states that the leveraged FlashMint contracts (FlashMintLeveragedAaveFL) reject:[aArbAAVE]onlyExchangeIssuance: TOO MANY COMPONENTS[aArbLINK, USDT (9150 wei dust)]ExchangeIssuance: TOO MANY EQUITY POSITIONSFlashMintDexV5(Base) doesn't help either — components are Aave aTokens with no DEX liquidity, so the per-component DEX swap loop would revert at the UniV3 quoter.This adds a single-purpose ~80-line contract that fills the gap: pulls the SetToken, calls
DebtIssuanceModuleV3.redeem, then for each component either burns the aToken viaIPool.withdrawto deliver underlying, or transfers the component as-is. State-transparent across a future operator cleanup that swaps aToken positions for underlying positions inside the SetToken.What's added
contracts/exchangeIssuance/AaveV3DeleveredRedeemer.sol— the contracttest/integration/arbitrum/aaveV3DeleveredRedeemer.spec.ts— 10 specs, all passing on an Arbitrum fork at block 459_500_000utils/deploys/deployExtensions.ts—deployAaveV3DeleveredRedeemer(pool, issuanceModule)helpertest/integration/arbitrum/addresses.ts— addsaLink,link,link2xtotokensand the Aave V3 Arbitrum Collector towhalesDesign notes
_tryGetUnderlyingusestry IAToken(component).UNDERLYING_ASSET_ADDRESS() catchso the same bytecode handles[aArbAAVE]today and[AAVE]after a future cleanup, and[aArbLINK, USDT]today and[LINK](or[LINK, USDT]) tomorrow. SDK config doesn't need to change across the transition._recipient = address(0)substitutesmsg.senderin the contract, letting SDKs encode the calldata without knowing the connected wallet.msg.senderand never holds the SetToken between calls.Verifies the load-bearing assumptions in the test (the user explicitly asked)
aArbAAVE.UNDERLYING_ASSET_ADDRESS()returns the canonical Arbitrum AAVE address. Same foraArbLINK/LINK. (Spec:aToken interface assumption.)IPool.withdraw(asset, type(uint256).max, recipient)burns the caller's full aToken balance and returns the actual underlying delivered. (Spec:IPool.withdraw(MaxUint256) semantics.)Test plan
yarn buildcleanyarn lintclean (pre-commit hook)yarn test:integration:arbitrum test/integration/arbitrum/aaveV3DeleveredRedeemer.spec.ts— 10/10 passing locally on Arbitrum fork at block 459_500_000index-deploymentsstage 022 + SDK PR (separate PRs after this merges and0.45.4ships)