Skip to content

feat: FlashMintAaveDelevered for Arbitrum AAVE2x/LINK2x - #226

Merged
ckoopmann merged 3 commits into
masterfrom
arbitrum-aave-delevered-redeemer
May 13, 2026
Merged

ckoopmann merged 3 commits into
masterfrom
arbitrum-aave-delevered-redeemer

Conversation

@ckoopmann

Copy link
Copy Markdown
Collaborator

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:

Product Components Revert
AAVE2x [aArbAAVE] only ExchangeIssuance: TOO MANY COMPONENTS
LINK2x [aArbLINK, USDT (9150 wei dust)] ExchangeIssuance: TOO MANY EQUITY POSITIONS

FlashMintDexV5 (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 via IPool.withdraw to 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 contract
  • test/integration/arbitrum/aaveV3DeleveredRedeemer.spec.ts — 10 specs, all passing on an Arbitrum fork at block 459_500_000
  • utils/deploys/deployExtensions.ts — deployAaveV3DeleveredRedeemer(pool, issuanceModule) helper
  • test/integration/arbitrum/addresses.ts — adds aLink, link, link2x to tokens and the Aave V3 Arbitrum Collector to whales

Design notes

  • State-transparency: _tryGetUnderlying uses try IAToken(component).UNDERLYING_ASSET_ADDRESS() catch so 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 sentinel: passing _recipient = address(0) substitutes msg.sender in the contract, letting SDKs encode the calldata without knowing the connected wallet.
  • No issuance. These products are deprecated; users only need an exit ramp.
  • No flash loan, no DEX, no per-SetToken approval, no per-component swap data. The redeemer pulls the SetToken from msg.sender and never holds the SetToken between calls.

Verifies the load-bearing assumptions in the test (the user explicitly asked)

  1. aArbAAVE.UNDERLYING_ASSET_ADDRESS() returns the canonical Arbitrum AAVE address. Same for aArbLINK/LINK. (Spec: aToken interface assumption.)
  2. 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 build clean
  • yarn lint clean (pre-commit hook)
  • yarn test:integration:arbitrum test/integration/arbitrum/aaveV3DeleveredRedeemer.spec.ts — 10/10 passing locally on Arbitrum fork at block 459_500_000
  • CI green
  • Companion index-deployments stage 022 + SDK PR (separate PRs after this merges and 0.45.4 ships)

ckoopmann added 3 commits May 5, 2026 16:30
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.
@ckoopmann ckoopmann changed the title feat: AaveV3DeleveredRedeemer for Arbitrum AAVE2x/LINK2x feat: FlashMintAaveDelevered for Arbitrum AAVE2x/LINK2x May 13, 2026
@ckoopmann
ckoopmann merged commit 1e239fd into master May 13, 2026
3 checks passed
@ckoopmann
ckoopmann deleted the arbitrum-aave-delevered-redeemer branch May 13, 2026 12:51
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.46.0 🎉

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