Complete Case Study: Euler Hooks (External Liquidity Example)
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:
External Balances: Tokens are in Euler vaults, not PoolManager → Need
MetadataRequestGeneratorWithdrawal Limits: Vaults have maximum withdrawal amounts → Need limits fetching logic
Yield Accrual: Balances increase over time from lending yield → Need periodic balance updates
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:
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
// ]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)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()
},
);
}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 statesBlock 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 heightKey Takeaways from Euler
Balance Slot Detection: Essential for hooks with external token holdings
Lens Contract Pattern: Powerful technique for complex multi-call queries using state overrides
Limits-Based Estimation: Provides more accurate swap amount samples than balance-based
Default Orchestrator: Often sufficient even for complex hooks like Euler
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