Complete Case Study: Euler Hooks (External Liquidity Example)

⚠️ Important Context: Euler represents a hook with EXTERNAL LIQUIDITY. This case study demonstrates implementing custom metadata generators and parsers. If your hook is Composable and uses internal PoolManager liquidity, your hook should be already indexed by Tycho.

This section provides a comprehensive walkthrough of the Euler hook integration as a real-world example of handling external liquidity.

Euler Vault Architecture

What is Euler? Euler is a lending protocol that allows users to deposit tokens into vaults to earn yield. Each vault is an ERC-4626 compliant contract that manages deposits and withdrawals.

Euler is a standalone protocol, that designed an interface to be Hook-compliant, allowing it to be accessible by UniswapV4 Pools. This is a common pattern with current hooks, and are considered by Tycho Hooks with External Liquidity.

You can learn more about the protocol here

Euler Hook Pattern (External Liquidity):

┌──────────────────────────┐
│  Uniswap V4 Euler Hook   │
│  (Liquidity Coordinator) │
└────────────┬─────────────┘

             │ Manages deposits/withdrawals

┌──────────────────────────┐
│  Euler Vault Contract    │  ← EXTERNAL liquidity storage
│  - Token0 deposited      │
│  - Token1 deposited      │
│  - Earns lending yield   │
└──────────────────────────┘

Contrast with Internal Liquidity:

Internal Liquidity Hook (No Custom Code Needed):
┌──────────────────────────┐
│  Uniswap V4 Hook         │
└────────────┬─────────────┘


┌──────────────────────────┐
│  PoolManager (ERC6909)   │  ← INTERNAL liquidity storage
│  - Automatic extraction  │
└──────────────────────────┘

Why Euler Requires Custom Implementation:

  1. External Balances: Tokens are in Euler vaults, not PoolManager → Need MetadataRequestGenerator

  2. Withdrawal Limits: Vaults have maximum withdrawal amounts → Need limits fetching logic

  3. Yield Accrual: Balances increase over time from lending yield → Need periodic balance updates

  4. Multiple Vaults: Each token pair might use different vault addresses → Need parser logic

What Euler Does NOT Need:

  • ❌ Custom Hook Orchestrator (default works fine)

  • ❌ Special entrypoint encoding (standard Uniswap V4 swaps)

  • ❌ Custom state transformations

Implementation Walkthrough

1. Balance Collection

Objective: Query the current token reserves in the Euler vaults.

Approach: Euler hooks implement a getReserves() function that returns the current balances of both tokens.

Code:

// From: euler/metadata_generator.rs

fn create_balance_request(
    &self,
    component: &ProtocolComponent,
    block: &Block,
    hook_address: &Address,
) -> Result<MetadataRequest, MetadataError> {
    Ok(MetadataRequest {
        request_type: MetadataRequestType::ComponentBalance {
            token_addresses: component.tokens.clone(),
        },
        routing_key: "rpc_default".to_string(),
        generator_name: "euler".to_string(),
        transport: RpcTransport::new(
            self.rpc_url.clone(),
            "eth_call".to_string(),
            vec![
                json!({
                    "to": hook_address,
                    "data": "0x0902f1ac" // getReserves() selector
                }),
                json!(format!("0x{:x}", block.number)),
            ],
        ),
    })
}

Response Format:

0x
  0000000000000000000000000000000000000000000000000de0b6b3a7640000  // reserve0 (1e18)
  0000000000000000000000000000000000000000000000000de0b6b3a7640000  // reserve1 (1e18)

Parsing:

// From: euler/metadata_generator.rs

fn parse_balance_response(&self, res_str: &str) -> Result<MetadataValue, MetadataError> {
    // Extract two 32-byte values
    let balance_0 = Bytes::from(&res_str[0..64]);
    let balance_1 = Bytes::from(&res_str[64..128]);

    // Map to sorted tokens
    let mut tokens = component.tokens.clone();
    tokens.sort();

    let mut balances = HashMap::new();
    balances.insert(tokens[0].clone(), balance_0);
    balances.insert(tokens[1].clone(), balance_1);

    Ok(MetadataValue::Balances(balances))
}

2. Limits Collection Using Lens Contract

Objective: Determine the maximum swap amounts for each direction (token0→token1, token1→token0).

Challenge: Euler vaults have withdrawal limits that depend on available liquidity, which requires complex calculations involving multiple contract calls.

Solution: Deploy a "lens" contract via state overrides that performs the calculation in a single eth_call.

Lens Contract Pattern:

// Simplified EulerLensContract
contract EulerLensContract {
    address public hook; // Stored in slot 0
    
    function getLimits(address tokenIn, address tokenOut)
        external
        view
        returns (uint256 realInLimit, uint256 realOutLimit)
    {
        IEulerSwap pool = IEulerSwap(hookAddress);

        // Step 1: Get the protocol limits
        (uint256 inLimit, uint256 outLimit) = pool.getLimits(tokenIn, tokenOut);

        // If no limits returned (e.g., not authorized), return zeros
        if (inLimit == 0 && outLimit == 0) {
            return (0, 0);
        }

        // Step 2: Compute quotes in both directions
        uint256 quotedOutFromIn;
        uint256 requiredInFromOut;
        bool exactInSucceeded = false;
        bool exactOutSucceeded = false;

        // Try exactIn = inLimit
        try pool.computeQuote(tokenIn, tokenOut, inLimit * 99 / 100, true) returns (uint256 quotedOut) {
            quotedOutFromIn = quotedOut;
            exactInSucceeded = true;
        } catch {}

        // Try exactOut = outLimit
        try pool.computeQuote(tokenIn, tokenOut, outLimit * 99 / 100, false) returns (uint256 requiredIn) {
            requiredInFromOut = requiredIn;
            exactOutSucceeded = true;
        } catch {}

        // Step 3: If both failed, revert
        if (!exactInSucceeded && !exactOutSucceeded) {
            revert QuoteComputationFailed();
        }

        // Step 4: Keep the smallest valid limits
        if (exactInSucceeded && exactOutSucceeded) {
            // Both succeeded - take the minimum of both approaches
            uint256 outLimitFromIn = quotedOutFromIn < outLimit ? quotedOutFromIn : outLimit;
            uint256 inLimitFromOut = requiredInFromOut < inLimit ? requiredInFromOut : inLimit;

            // Choose the approach that gives the smallest limits
            realInLimit = inLimitFromOut < inLimit ? inLimitFromOut : inLimit;
            realOutLimit = outLimitFromIn < outLimit ? outLimitFromIn : outLimit;
        } else if (exactInSucceeded) {
            // Only exactIn succeeded
            realInLimit = inLimit;
            realOutLimit = quotedOutFromIn < outLimit ? quotedOutFromIn : outLimit;
        } else {
            // Only exactOut succeeded
            realInLimit = requiredInFromOut < inLimit ? requiredInFromOut : inLimit;
            realOutLimit = outLimit;
        }
    }
}

Request Generation:

// From: euler/metadata_generator.rs

fn create_limits_request(
    &self,
    component: &ProtocolComponent,
    block: &Block,
    hook_address: &Address,
    token_pair: &[Address],
) -> Result<MetadataRequest, MetadataError> {
    let lens_address = "0x0000000000000000000000000000000000001337";
    let lens_bytecode_hex = hex::encode(EULER_LENS_BYTECODE_BYTES);

    // Encode getLimits(address,address) call
    let token0_hex = &token_pair[0].to_string()[2..];  // Remove 0x prefix
    let token1_hex = &token_pair[1].to_string()[2..];
    let calldata = format!("0xaaed87a3{token0_hex}{token1_hex}");

    Ok(MetadataRequest {
        request_type: MetadataRequestType::Limits {
            token_pair: token_pair.to_vec(),
        },
        routing_key: "rpc_default".to_string(),
        generator_name: "euler".to_string(),
        transport: RpcTransport::new(
            self.rpc_url.clone(),
            "eth_call".to_string(),
            vec![
                json!({
                    "to": lens_address,
                    "data": calldata
                }),
                json!(format!("0x{:x}", block.number)),
                json!({
                    lens_address: {
                        // Deploy lens bytecode at deterministic address
                        "code": format!("0x{}", lens_bytecode_hex),
                        "state": {
                            // Store hook address in slot 0
                            "0x0000000000000000000000000000000000000000000000000000000000000000":
                                format!("0x{:0>64}", &hook_address.to_string()[2..])
                        }
                    }
                }),
            ],
        ),
    })
}

Response Format:

0x
  0000000000000000000000000000000000000000000000056bc75e2d63100000  // limit0 (100e18)
  0000000000000000000000000000000000000000000000056bc75e2d63100000  // limit1 (100e18)

Parsing:

// From: euler/metadata_generator.rs

fn parse_limits_response(
    &self,
    component: &ProtocolComponent,
    request: &MetadataRequest,
    res_str: &str,
    token_pair: &[Address],
) -> Result<MetadataValue, MetadataError> {
    // Extract limits
    let limit_0 = Bytes::from(&res_str[0..64]);
    let limit_1 = Bytes::from(&res_str[64..128]);

    // Create entrypoint for the limits call (for reference)
    let limits_entrypoint = create_euler_limits_entrypoint(
        component,
        hook_address,
        token_pair,
    )?;

    Ok(MetadataValue::Limits(vec![
        (token_pair[0].clone(), (limit_0, limit_1, Some(limits_entrypoint)))
    ]))
}

3. Entrypoint Generation with Detected Slots

Objective: Generate entrypoints that simulate swaps with correct balance overwrites for both PoolManager and external vault tokens.

Process:

  1. Estimate Swap Amounts (using limits):

// From: entrypoint_generator.rs

let estimator = DefaultSwapAmountEstimator::with_limits();
let swap_amounts = estimator.estimate_swap_amounts(&metadata, &component.tokens)?;

// For Euler with limits = [100e18, 100e18]:
// swap_amounts = [
//     (token0, token1, 1e18),   // 1% of limit
//     (token0, token1, 10e18),  // 10% of limit
//     (token0, token1, 50e18),  // 50% of limit
//     (token0, token1, 95e18),  // 95% of limit
// ]
  1. Detect Balance Slots (for wstETH, WETH, etc.):

// From: entrypoint_generator.rs

let detected_slots = balance_slot_detector
    .detect_balance_slots(
        &component.tokens,
        pool_manager,
        &block.hash,
    )
    .await?;

// Returns mapping: token_address → storage_slot
// Example: wstETH → 0x0000...0001 (slot 1 for standard ERC20)
  1. Build State Overrides:

let mut state_overrides = HashMap::new();

// A. Deploy V4MiniRouter
state_overrides.insert(
    router_address,
    AccountOverrides {
        code: Some(V4_MINI_ROUTER_BYTECODE),
        balance: None,
        nonce: None,
        slots: None,
    },
);

// B. Set ERC6909 balances in PoolManager
let erc6909_slot = calculate_erc6909_balance_slot(&sender, &token_in);
state_overrides.insert(
    pool_manager,
    AccountOverrides {
        slots: Some(StorageOverride::Diff(
            vec![(erc6909_slot, amount_in * 2)].into_iter().collect()
        )),
        ..Default::default()
    },
);

// C. Set detected ERC20 balance slots
if let Some(token_in_slot) = detected_slots.get(&token_in) {
    state_overrides.insert(
        token_in.clone(),
        AccountOverrides {
            slots: Some(StorageOverride::Diff(
                vec![(token_in_slot.clone(), amount_in * 2)].into_iter().collect()
            )),
            ..Default::default()
        },
    );
}
  1. Create Entrypoint:

// Build V4Router execute() call
let pool_key = build_pool_key_from_component(component)?;
let params = ExactInputSingleParams {
    pool_key,
    zero_for_one: true,
    amount_in,
    amount_out_minimum: Bytes::from([0u8]),
    hook_data: Bytes::from([0u8]),
};

let actions = vec![
    V4RouterAction::SWAP_EXACT_IN_SINGLE,
    V4RouterAction::SETTLE_ALL,
    V4RouterAction::TAKE_ALL,
];

let calldata = encode_execute_call(actions, vec![params])?;

let entrypoint = EntryPointWithTracingParams {
    entry_point: EntryPoint {
        external_id: format!("swap_{}_{}_{}_{}",
            component.id, token_in, token_out, amount_in),
        target: router_address,
        signature: "execute(bytes,bytes[])".to_string(),
    },
    params: TracingParams::RPCTracer(RPCTracerParams {
        caller: Some(sender),
        calldata,
        state_overrides: Some(state_overrides),
        prune_addresses: None,
    }),
};

4. Full Processing Flow

Initialization (one-time):

1. Load all uniswap_v4_hooks components from database
2. Filter for components with swap hook permissions
3. Check if entrypoints already exist
   - Has entrypoints → State = TracingComplete
   - No entrypoints → State = Unprocessed
4. Cache all components and states

Block Processing (per block):

1. Extract components with balance/state changes
2. Filter for swap hook permissions
3. Categorize:
   - Unprocessed → Full processing list
   - TracingComplete → Balance-only list
   - Failed (retryable) → Full processing list
   - Failed (paused) → Skip

4. Collect Metadata:
   - Full processing: getLimits() + getReserves()
   - Balance-only: getReserves()

5. Check for metadata errors:
   - Errors → Mark as Failed, increment retry_count
   - retry_count >= pause_after_retries → Set "paused" attribute

6. Process each component via orchestrator:
   - Generate entrypoints (if full processing)
   - Inject balances into component
   - Inject limits for optimization
   - Update block_changes

7. Delegate to inner DCI:
   - Trace entrypoints
   - Store results in database
   - Prune old data

8. Handle finality:
   - Prune cache layers below finalized height

Key Takeaways from Euler

  1. Balance Slot Detection: Essential for hooks with external token holdings

  2. Lens Contract Pattern: Powerful technique for complex multi-call queries using state overrides

  3. Limits-Based Estimation: Provides more accurate swap amount samples than balance-based

  4. Default Orchestrator: Often sufficient even for complex hooks like Euler

  5. State Override Composition: Combine router deployment, ERC6909 overwrites, and ERC20 overwrites in a single call

The Euler implementation demonstrates that with proper metadata collection and entrypoint generation, the Hooks DCI can handle even complex external liquidity scenarios.

Last updated