Skip to content

feat: Intermediate token migration - #218

Merged
ckoopmann merged 11 commits into
masterfrom
intermediate-token-migration
Jan 16, 2026
Merged

ckoopmann merged 11 commits into
masterfrom
intermediate-token-migration

Conversation

@ckoopmann

@ckoopmann ckoopmann commented Jan 12, 2026 •

Copy link
Copy Markdown
Collaborator

IntermediateMigrationExtension

Summary

This PR introduces IntermediateMigrationExtension, a standalone contract for migrating ETH2xFLI and BTC2xFLI from holding their respective 2X tokens directly to holding IntermediateTokens (which wrap the 2X tokens).

Before Migration:
  ETH2xFLI → [ETH2X]
  BTC2xFLI → [BTC2X]

After Migration:
  ETH2xFLI → [IntermediateToken]
  BTC2xFLI → [IntermediateToken]
  IntermediateToken → [ETH2X or BTC2X]

Motivation

The migration enables a layered token structure where ETH2xFLI and BTC2xFLI hold IntermediateTokens instead of ETH2X/BTC2X directly. This provides flexibility for future upgrades and token management while maintaining the same underlying exposure for token holders.

Key benefit: Streaming fees can be accrued on the IntermediateToken, which was not possible when holding ETH2X/BTC2X directly.

Why a New Extension?

The existing MigrationExtension was designed for the first migration (WETH → ETH2X), where the wrappedSetToken (ETH2X) could be minted directly from aWETH in a single step.

The new migration (ETH2X → IntermediateToken) is fundamentally different:

  1. Nested Token Structure: IntermediateToken wraps ETH2X, so we must first issue ETH2X before we can issue IntermediateToken.

  2. Leveraged Underlying: ETH2X is a leveraged token with aWETH (equity) and USDC (debt) components. When issuing ETH2X, we receive USDC that must be sold for WETH. When redeeming, we must buy USDC to repay the debt. The original extension has no debt token handling.

  3. Different Pool Structure: The migration requires an ETH2X/IntermediateToken pool rather than a WETH/wrappedSetToken pool.

Design Options Considered

Option A: WETH/IntermediateToken Pool (Two-Hop Trade)

  • Approach: Same pool pattern as original MigrationExtension
  • Trade Path: ETH2X → WETH → IntermediateToken (two-hop)
  • Liquidity: Requires WETH + IntermediateToken for pool

Option B: ETH2X/IntermediateToken Pool (Single-Hop Trade) ✓

  • Approach: Direct pool between the two SetTokens
  • Trade Path: ETH2X → IntermediateToken (single-hop)
  • Liquidity: Uses ETH2X + IntermediateToken for pool

We chose Option B because:

  1. Simpler liquidity provisioning: The extension already issues both ETH2X and IntermediateToken during migration, so these tokens are naturally available for pool liquidity without additional conversions
  2. Single-hop trade: More gas efficient and simpler execution
  3. Natural token flow: The migration converts ETH2X → IntermediateToken, which matches the pool pair directly

Key Design Decisions

Standalone Implementation

IntermediateMigrationExtension is a standalone contract that does not inherit from MigrationExtension. This design was chosen because:

  1. Minimal code reuse: Only ~65 lines of the parent would have been reused
  2. Significant dead code: Inheritance would include ~155 lines of unused Aave/Balancer flash loan code
  3. Cleaner architecture: No need for no-op override hacks to reduce contract size
  4. Independent evolution: Each contract can change without affecting the other
  5. Smaller bytecode: 17.6 KB vs 24 KB with inheritance

MigrationExtension.sol is unchanged - this PR does not modify the original contract.

Atomic Migration

The entire migration executes in a single transaction via migrateAtomicMorpho():

  1. Create and initialize Uniswap V3 pool
  2. Borrow WETH via Morpho flash loan
  3. Convert WETH to aWETH via Aave
  4. Issue ETH2X (receive USDC debt component)
  5. Sell USDC for WETH
  6. Issue IntermediateToken from ETH2X
  7. Mint LP position in ETH2X/IntermediateToken pool
  8. Execute migration trade (SetToken sells ETH2X for IntermediateToken)
  9. Remove all liquidity from pool
  10. Redeem all tokens back to WETH (buy USDC to repay debt)
  11. Repay flash loan

This atomic approach:

  • No setup transactions required: Pool creation and liquidity are handled in the same transaction
  • MEV protection: No opportunity for front-running between steps
  • Simpler operator flow: Single function call executes everything

Flash Loan Source

The extension uses Morpho flash loans only (0% fee). Aave and Balancer flash loan support was removed to reduce contract size since Morpho provides the same functionality without fees.

IntermediateToken Deployment Options

The IntermediateToken can be deployed on either:

Option Controller Issuance Module Use Case
Original Set Protocol 0xa4c8...A349 BasicIssuanceModule Same controller as ETH2xFLI
Index Fork 0xD246...603A DebtIssuanceModuleV2 Same controller as ETH2X

The contract supports both via the useBasicIssuance flag:

  • true = Use BasicIssuanceModule (original Set Protocol)
  • false = Use DebtIssuanceModule (Index fork)

Current expectation: Deploy on Original Set Protocol with BasicIssuanceModule.

Pool Fee Tier & Subsidy Economics

The migration uses a Uniswap V3 pool where the operator is the sole liquidity provider. The pool fee tier determines whether a subsidy is needed:

Fee Tier Subsidy Required Operator Profit
0.3% (3000) Yes (~7-10 ETH) Break-even after subsidy
1% (10000) No +16.5 ETH (ETH2xFLI), +0.2 WBTC (BTC2xFLI)

Why the difference?

With a 0.3% fee tier, the operator captures 0.3% of the trade volume as LP fees, but this doesn't cover:

  • USDC swap costs (~0.1% round-trip for debt token handling)
  • Price impact from the large trade relative to pool liquidity

With a 1% fee tier, the operator captures 1% of the trade volume, which more than covers all costs and generates profit:

ETH2xFLI (1% fee tier):

Trade volume:          ~205,000 ETH2X
LP fees captured:      ~1% = ~2,050 ETH2X worth
USDC swap costs:       ~0.1%
Net profit:            +16.5 ETH returned to operator

BTC2xFLI (1% fee tier):

Trade volume:          ~15,000 BTC2X
LP fees captured:      ~1%
Net profit:            +0.205 WBTC returned to operator

Recommendation: Use 1% fee tier (10000) with tick range ±200 for profitable migration without subsidy.

Operator Transaction Sequence

The operator must execute the following transactions:

Phase 1: Setup (One-time)

  1. Deploy IntermediateToken (anyone can call SetTokenCreator)

    SetTokenCreator.create([ETH2X], [1e18], [IssuanceModule, StreamingFeeModule], manager, "ETH2X Fee Wrapper", "ETH2XFW")
    
  2. Initialize IssuanceModule on IntermediateToken

    BasicIssuanceModule.initialize(intermediateToken, address(0))
    
  3. Initialize StreamingFeeModule on IntermediateToken

    StreamingFeeModule.initialize(intermediateToken, feeSettings)
    
  4. Deploy IntermediateMigrationExtension

  5. Add extension to BaseManager (operator only)

    baseManager.addExtension(intermediateMigrationExtension)
    
  6. Authorize BaseManager initialization (methodologist only)

    baseManager.authorizeInitialization()
    

Phase 2: Migration (Single Transaction)

  1. Execute atomic migration (operator only)
    // Single transaction: creates pool, adds liquidity, trades, removes liquidity
    intermediateMigrationExtension.migrateAtomicMorpho(
      AtomicMigrationParams({
        supplyLiquidityAmount0Desired: poolLiquidityAmount,
        supplyLiquidityAmount1Desired: poolLiquidityAmount,
        supplyLiquidityAmount0Min: 0,
        supplyLiquidityAmount1Min: 0,
        exchangeName: "UniswapV3ExchangeAdapter",
        underlyingTradeUnits: tradeUnits,
        wrappedSetTokenTradeUnits: minReceiveUnits,
        exchangeData: exchangeData,
        redeemLiquidityAmount0Min: 0,
        redeemLiquidityAmount1Min: 0,
        isUnderlyingToken0: isNestedToken0,
        tickLower: -200,
        tickUpper: 200,
        poolFee: 10000,  // 1% fee tier
        sqrtPriceX96: 79228162514264337593543950336  // 1:1 price
      }),
      underlyingLoanAmount,
      0  // No subsidy needed - migration is profitable
    );

Required Access

Account Role Required For
Operator (0x6904...E8A4) BaseManager operator All migration operations
Methodologist BaseManager methodologist Authorize initialization

Implementation

Contract Architecture

IntermediateMigrationExtension is a standalone contract inheriting from:

  • BaseExtension - Provides onlyOperator modifier and invokeManager
  • IERC721Receiver - Required for receiving Uniswap V3 LP NFTs

Key Functions

External:

  • migrateAtomicMorpho(): Single-transaction migration with Morpho flash loan
  • onMorphoFlashLoan(): Flash loan callback
  • initialize(): Initialize TradeModule on SetToken
  • sweepTokens(): Recover any stuck tokens
  • onERC721Received(): ERC721 receiver for LP NFTs

Internal:

  • _migrateAtomic(): Core migration logic
  • _issueRequiredPoolTokens(): Issues both ETH2X and IntermediateToken for pool liquidity
  • _mintLiquidityPosition(): Mints Uniswap V3 LP position
  • _decreaseLiquidityPosition(): Removes liquidity and collects fees
  • _redeemExcessWrappedSetToken(): Redeems tokens back to WETH
  • _trade(): Executes trade via TradeModule
  • _sellDebtTokenForUnderlying(): Sells USDC received from issuing ETH2X
  • _buyDebtTokenWithUnderlying(): Buys USDC needed to redeem ETH2X
  • _getWrappedTokenRequiredUnits(): Handles both BasicIssuanceModule and DebtIssuanceModule
  • _issueWrappedToken() / _redeemWrappedToken(): Issue/redeem via configured module

State Variables

// Core tokens and modules
ISetToken public immutable setToken;           // ETH2xFLI or BTC2xFLI
IERC20 public immutable underlyingToken;       // WETH or WBTC
IERC20 public immutable aaveToken;             // aWETH or aWBTC
ISetToken public immutable wrappedSetToken;    // IntermediateToken
ISetToken public immutable nestedSetToken;     // ETH2X or BTC2X
IERC20 public immutable debtToken;             // USDC

// Modules and external contracts
ITradeModule public immutable tradeModule;
INonfungiblePositionManager public immutable nonfungiblePositionManager;
IMorpho public immutable morpho;
IPool public immutable POOL;                   // Aave V3 Pool
ISwapRouter public immutable swapRouter;       // Uniswap V3 SwapRouter
address public immutable wrappedTokenIssuanceModule;
IDebtIssuanceModule public immutable nestedSetTokenIssuanceModule;

// Configuration
bool public immutable useBasicIssuance;        // true = BasicIssuanceModule
uint256[] public tokenIds;                     // UniV3 LP Token IDs

Contract Size

IntermediateMigrationExtension: 17.630 KB (limit: 24.576 KB)

Testing

Integration Test

test/integration/ethereum/intermediateMigrationExtension.spec.ts

Forks mainnet at block 24219075 where:

  • ETH2xFLI and BTC2xFLI already hold ETH2X/BTC2X (first migration already completed)
  • Manager is a Gnosis Safe (requires deploying a new BaseManager)
  • Both ETH2X and BTC2X use the same issuance module (0x04b59F9F09750C044D7CfbC177561E409085f0f3)

Test executes for both ETH2xFLI and BTC2xFLI:

  1. Deploy new BaseManager with Gnosis Safe as operator
  2. Transfer SetToken manager from Safe to BaseManager
  3. Deploy IntermediateToken (SetToken wrapping ETH2X/BTC2X 1:1)
  4. Initialize StreamingFeeModule on IntermediateToken
  5. Deploy IntermediateMigrationExtension
  6. Execute atomic migration via Morpho flash loan (single transaction)
  7. Verify streaming fee accrual works on IntermediateToken (ETH2xFLI only)

Test Results

30 passing tests covering both ETH2xFLI and BTC2xFLI:

Migration Tests (per token)

  • ✓ should have nested token as the primary component (before migration)
  • ✓ should have IntermediateToken deployed with nested token as component
  • ✓ should have IntermediateMigrationExtension as an extension
  • ✓ should have IntermediateToken as a component and nested token removed (after migration)
  • ✓ should have positive IntermediateToken position
  • ✓ IntermediateToken should still have nested token as its only component
  • ✓ should preserve implied nested token exposure (within slippage tolerance)

FLIRedemptionHelper Tests (per token, before and after migration)

  • ✓ should report not migrated / should report migrated
  • ✓ should correctly calculate nested token received on redemption
  • ✓ should redeem FLI directly to nested token / through IntermediateToken

Streaming Fee Tests (ETH2xFLI only)

  • ✓ should have FLI fee settings (5% streaming fee)
  • ✓ should revert when trying to accrue fees on FLI
  • ✓ should have StreamingFeeModule initialized on IntermediateToken
  • ✓ should have accrued fee pending (after 1 month simulation)
  • ✓ should accrue fees and mint to fee recipient

ETH2X Exposure Preservation

ETH2X unit before migration: 0.3240 per FLI token
Implied ETH2X unit after:    0.3177 per FLI token
Preservation:                98.0%

BTC2X Exposure Preservation

BTC2X unit before migration: 0.2579 per FLI token
Implied BTC2X unit after:    0.2529 per FLI token
Preservation:                98.0%

Migration Economics (at test block 24219075, 1% fee tier)

ETH2xFLI:

Pool fee tier:                1% (10000)
Tick range:                   ±200
Subsidy provided:             0 ETH
Profit returned to operator:  +16.53 ETH
Flash loan source:            Morpho (0% fee)
Flash loan amount:            ~7,970 ETH

BTC2xFLI:

Pool fee tier:                1% (10000)
Tick range:                   ±200
Subsidy provided:             0 WBTC
Profit returned to operator:  +0.205 WBTC
Flash loan source:            Morpho (0% fee)
Flash loan amount:            ~9.7 WBTC

Streaming Fee Accrual (1 month simulation)

IntermediateToken supply before: 204,111 tokens
Fee rate:                        5% annual (same as ETH2xFLI)
Time elapsed:                    1 month
Pending fee:                     ~0.16%
Minted to fee recipient:         327.44 IntermediateTokens

This demonstrates that streaming fees can be accrued on the IntermediateToken, which was a key motivation for this migration.

How to Run Tests

  1. Start a local Hardhat node:
yarn chain
  1. Run the integration test:
INTEGRATIONTEST=true yarn test test/integration/ethereum/intermediateMigrationExtension.spec.ts

Files Changed

  • contracts/adapters/IntermediateMigrationExtension.sol - New standalone migration extension
  • contracts/adapters/FLIRedemptionHelper.sol - New redemption helper for FLI holders
  • contracts/interfaces/external/uniswap-v3/INonfungiblePositionManager.sol - Added createAndInitializePoolIfNecessary
  • hardhat.config.ts - Optimizer override for IntermediateMigrationExtension
  • utils/deploys/deployExtensions.ts - Deploy helpers for new contracts
  • test/integration/ethereum/intermediateMigrationExtension.spec.ts - Comprehensive integration tests

Note: MigrationExtension.sol is not modified in this PR.

FLIRedemptionHelper

A helper contract that allows FLI token holders to redeem their tokens for the underlying 2X tokens (ETH2X/BTC2X). Works transparently before and after migration:

Before Migration:

User provides FLI → contract redeems FLI → User receives ETH2X/BTC2X

After Migration:

User provides FLI → contract redeems FLI → receives IntermediateToken
                  → contract redeems IntermediateToken → User receives ETH2X/BTC2X

Key features:

  • Automatically detects migration state by checking FLI components
  • Uses DebtIssuanceModuleV2 (0x69a592D2129415a4A1d1b1E309C17051B7F28d57) which has no hooks
  • View functions: isMigrated(), getNestedTokenReceivedOnRedemption()

@ckoopmann
ckoopmann force-pushed the intermediate-token-migration branch from d356bfd to 8d0c16e Compare January 12, 2026 13:31
Comment thread test/integration/ethereum/intermediateMigrationExtension.spec.ts Outdated
Comment thread test/integration/ethereum/intermediateMigrationExtension.spec.ts Outdated

@pblivin0x pblivin0x left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the start and use of inheritance. May I request we bump the fork block number and add BTC tests

@ckoopmann
ckoopmann force-pushed the intermediate-token-migration branch from 13ff19f to 4399a73 Compare January 15, 2026 16:27
@ckoopmann
ckoopmann requested a review from pblivin0x January 15, 2026 16:30

/* ============ State Variables ============ */

ISetToken public immutable fliToken; // ETH2xFLI or BTC2xFLI

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: This will mean deploying one helper contract per fli. Should be fine imo and is in line with the migration extension (which will also be specific to the respective token). But lmk if youdisagree

Comment on lines +339 to +353
// Fund operator with underlying for subsidy
const operatorAddress = await operator.getAddress();

// Get underlying tokens for seeding and subsidy
if (config.whale) {
// WBTC: transfer from whale
const whaleSigner = await impersonateAccount(config.whale);
await owner.wallet.sendTransaction({ to: config.whale, value: ether(1) });

const aTokenPerNested = await nestedToken.getDefaultPositionRealUnit(config.aToken);
const underlyingNeededForSeed = config.seedAmount.mul(2).mul(aTokenPerNested).mul(110).div(100).div(ether(1));
const totalUnderlyingForSetup = underlyingNeededForSeed.add(config.maxSubsidy);

await underlyingToken.connect(whaleSigner).transfer(owner.address, totalUnderlyingForSetup);
await underlyingToken.transfer(operatorAddress, config.maxSubsidy);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will the migration need a subsidy?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily. The economics of the migration can be impacted by configuring the liquidity position on uniswap accordingly. Even to the extend where the migration extension will make a significant profit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I adjusted the tests to use a 1% pool for the migration instead of 0.3% which makes the migration actually profitable for us, and thereby removes the need for subsidy. (See Migration Economics section in PR summary). Let's discuss offline what exact config we will want to use.

Comment on lines +424 to +436
// Mint seed liquidity position
const tickLower = -60;
const tickUpper = 60;
await intermediateMigrationExtension.mintLiquidityPosition(
config.seedAmount,
config.seedAmount,
ZERO,
ZERO,
tickLower,
tickUpper,
3000,
isNestedToken0,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the seed liquidity tick important to the migration? or is it just for initialization?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only for initialization, the actual liquidity provision is done in the migration itself.
It might be possible to do the initialization in the migration transaction atomically. For now I had just kept it as is to be in line with the previous migration, but I will see what it would take to also move the initialization into the migration tx.

@pblivin0x pblivin0x left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, a couple of small questions around subsidy and liquidity position initialization

@ckoopmann
ckoopmann merged commit f00f078 into master Jan 16, 2026
3 checks passed
@ckoopmann
ckoopmann deleted the intermediate-token-migration branch January 16, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants